diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61e878cc..bbd4e9be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,8 @@ jobs: run: git lfs pull - name: Start Postgres run: brew services start postgresql - - name: Select Xcode 13.3 - run: sudo xcode-select -s /Applications/Xcode_13.3.app + - name: Select Xcode 13.4.1 + run: sudo xcode-select -s /Applications/Xcode_13.4.1.app - name: Bootstrap run: make bootstrap - name: Run tests diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ComposableGameCenter.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ComposableGameCenter.xcscheme new file mode 100644 index 00000000..e2bcfe6d --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/ComposableGameCenter.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ComposableStoreKit.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ComposableStoreKit.xcscheme new file mode 100644 index 00000000..bfa40735 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/ComposableStoreKit.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CubePreviewPreview.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CubePreviewPreview.xcscheme index 98736cd7..2d30adf8 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/CubePreviewPreview.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/CubePreviewPreview.xcscheme @@ -28,6 +28,16 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/TrailerPreview.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/TrailerPreview.xcscheme index 17c24f0e..b8014582 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/TrailerPreview.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/TrailerPreview.xcscheme @@ -31,7 +31,7 @@ Self { .live( fetch: { - apiClient.apiRequest(route: .config(build: build.number()), as: ServerConfig.self) - .mapError { $0 as Error } - .eraseToEffect() + try await apiClient + .apiRequest(route: .config(build: build.number()), as: ServerConfig.self) } ) } diff --git a/App/isowords.xcodeproj/project.pbxproj b/App/isowords.xcodeproj/project.pbxproj index 4285b9bc..a4d7db16 100644 --- a/App/isowords.xcodeproj/project.pbxproj +++ b/App/isowords.xcodeproj/project.pbxproj @@ -1335,7 +1335,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -1353,6 +1353,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = iOS/iOS.entitlements; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 105; DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = iOS/Info.plist; @@ -1441,6 +1442,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = AppClip/AppClip.entitlements; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 105; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; @@ -1463,6 +1465,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = AppClip/AppClip.entitlements; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 105; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; @@ -1486,6 +1489,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = AppClip/AppClip.entitlements; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 105; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; @@ -1707,7 +1711,7 @@ DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Previews/LeaderboardsPreview/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1729,7 +1733,7 @@ DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Previews/LeaderboardsPreview/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1752,7 +1756,7 @@ DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Previews/LeaderboardsPreview/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1818,7 +1822,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -1874,7 +1878,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -1891,6 +1895,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = iOS/iOS.entitlements; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 105; DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = iOS/Info.plist; @@ -1915,6 +1920,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = iOS/iOS.entitlements; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 105; DEVELOPMENT_TEAM = VFRXY8HC3H; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = iOS/Info.plist; diff --git a/App/isowords.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/App/isowords.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 44f296e0..cc5fd627 100644 --- a/App/isowords.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/App/isowords.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,8 +24,8 @@ "repositoryURL": "https://github.com/pointfreeco/combine-schedulers", "state": { "branch": null, - "revision": "0ba3a562716efabb585ccb169450c5389107286b", - "version": "0.6.0" + "revision": "f7c8277f05f27a5bfb2f6ecccb0bad126ffcf472", + "version": "0.7.0" } }, { @@ -51,8 +51,8 @@ "repositoryURL": "https://github.com/vapor/sql-kit.git", "state": { "branch": null, - "revision": "89b0a0a5f110e77272fb5a775064a31bfc1f155c", - "version": "3.18.0" + "revision": "b7901df0611d3d7e869fa345020b3d0ae817c32d", + "version": "3.19.1" } }, { @@ -87,8 +87,8 @@ "repositoryURL": "https://github.com/pointfreeco/swift-composable-architecture", "state": { "branch": null, - "revision": "0a38f2c860a64a005afeb8a6fe186eb2818c9a3f", - "version": "0.38.3" + "revision": "108e3a536fcebb16c4f247ef92c2d7326baf9fe3", + "version": "0.39.0" } }, { @@ -150,8 +150,8 @@ "repositoryURL": "https://github.com/apple/swift-metrics.git", "state": { "branch": null, - "revision": "1c1408bf8fc21be93713e897d2badf500ea38419", - "version": "2.3.1" + "revision": "53be78637ecd165d1ddedc4e20de69b8f43ec3b7", + "version": "2.3.2" } }, { diff --git a/App/isowords.xcodeproj/xcshareddata/xcschemes/isowords.xcscheme b/App/isowords.xcodeproj/xcshareddata/xcschemes/isowords.xcscheme index e0125558..b7ac91d7 100644 --- a/App/isowords.xcodeproj/xcshareddata/xcschemes/isowords.xcscheme +++ b/App/isowords.xcodeproj/xcshareddata/xcschemes/isowords.xcscheme @@ -290,6 +290,66 @@ ReferencedContainer = "container:.."> + + + + + + + + + + + + + + + + + + + + + + + + ( - as type: A.Type, - file: StaticString = #file, - line: UInt = #line - ) -> Effect { - self - .mapError { ApiError(error: $0, file: file, line: line) } - .flatMap { data -> AnyPublisher in - do { - return try Just(jsonDecoder.decode(A.self, from: data)) - .setFailureType(to: ApiError.self) - .eraseToAnyPublisher() - } catch let decodingError { - do { - return try Fail( - error: jsonDecoder.decode(ApiError.self, from: data) - ).eraseToAnyPublisher() - } catch { - return Fail(error: ApiError(error: decodingError)).eraseToAnyPublisher() - } - } - } - .eraseToEffect() +public func apiDecode(_ type: A.Type, from data: Data) throws -> A { + do { + return try jsonDecoder.decode(A.self, from: data) + } catch let decodingError { + let apiError: Error + do { + apiError = try jsonDecoder.decode(ApiError.self, from: data) + } catch { + throw decodingError + } + throw apiError } } diff --git a/Sources/ApiClient/Client.swift b/Sources/ApiClient/Client.swift index f51fe504..b1042718 100644 --- a/Sources/ApiClient/Client.swift +++ b/Sources/ApiClient/Client.swift @@ -4,30 +4,26 @@ import Foundation import SharedModels public struct ApiClient { - public var apiRequest: - (ServerRoute.Api.Route) -> Effect<(data: Data, response: URLResponse), URLError> + public var apiRequest: @Sendable (ServerRoute.Api.Route) async throws -> (Data, URLResponse) public var authenticate: - (ServerRoute.AuthenticateRequest) -> Effect - public var baseUrl: () -> URL - public var currentPlayer: () -> CurrentPlayerEnvelope? - public var logout: () -> Effect - public var refreshCurrentPlayer: () -> Effect - public var request: (ServerRoute) -> Effect<(data: Data, response: URLResponse), URLError> - public var setBaseUrl: (URL) -> Effect + @Sendable (ServerRoute.AuthenticateRequest) async throws -> CurrentPlayerEnvelope + public var baseUrl: @Sendable () -> URL + public var currentPlayer: @Sendable() -> CurrentPlayerEnvelope? + public var logout: @Sendable () async -> Void + public var refreshCurrentPlayer: @Sendable () async throws -> CurrentPlayerEnvelope + public var request: @Sendable (ServerRoute) async throws -> (Data, URLResponse) + public var setBaseUrl: @Sendable (URL) async -> Void public init( - apiRequest: @escaping (ServerRoute.Api.Route) -> Effect< - (data: Data, response: URLResponse), URLError - >, - authenticate: @escaping (ServerRoute.AuthenticateRequest) -> Effect< - CurrentPlayerEnvelope, ApiError - >, - baseUrl: @escaping () -> URL, - currentPlayer: @escaping () -> CurrentPlayerEnvelope?, - logout: @escaping () -> Effect, - refreshCurrentPlayer: @escaping () -> Effect, - request: @escaping (ServerRoute) -> Effect<(data: Data, response: URLResponse), URLError>, - setBaseUrl: @escaping (URL) -> Effect + apiRequest: @escaping @Sendable (ServerRoute.Api.Route) async throws -> (Data, URLResponse), + authenticate: @escaping @Sendable (ServerRoute.AuthenticateRequest) async throws -> + CurrentPlayerEnvelope, + baseUrl: @escaping @Sendable () -> URL, + currentPlayer: @escaping @Sendable () -> CurrentPlayerEnvelope?, + logout: @escaping @Sendable () async -> Void, + refreshCurrentPlayer: @escaping @Sendable () async throws -> CurrentPlayerEnvelope, + request: @escaping @Sendable (ServerRoute) async throws -> (Data, URLResponse), + setBaseUrl: @escaping @Sendable (URL) async -> Void ) { self.apiRequest = apiRequest self.authenticate = authenticate @@ -45,8 +41,22 @@ public struct ApiClient { route: ServerRoute.Api.Route, file: StaticString = #file, line: UInt = #line - ) -> Effect { - self.apiRequest(route: route, as: Unit.self, file: file, line: line) + ) async throws -> (Data, URLResponse) { + do { + let (data, response) = try await self.apiRequest(route) + #if DEBUG + print( + """ + API: route: \(route), \ + status: \((response as? HTTPURLResponse)?.statusCode ?? 0), \ + receive data: \(String(decoding: data, as: UTF8.self)) + """ + ) + #endif + return (data, response) + } catch { + throw ApiError(error: error, file: file, line: line) + } } public func apiRequest( @@ -54,25 +64,35 @@ public struct ApiClient { as: A.Type, file: StaticString = #file, line: UInt = #line - ) -> Effect { - self.apiRequest(route) - .handleEvents( - receiveOutput: { - #if DEBUG - print( - """ - API: route: \(route), \ - status: \(($0.response as? HTTPURLResponse)?.statusCode ?? 0), \ - receive data: \(String(decoding: $0.data, as: UTF8.self)) - """ - ) - #endif - } - ) - .map { data, _ in data } - .apiDecode(as: A.self, file: file, line: line) - .print("API") - .eraseToEffect() + ) async throws -> A { + let (data, _) = try await self.apiRequest(route: route, file: file, line: line) + do { + return try apiDecode(A.self, from: data) + } catch { + throw ApiError(error: error, file: file, line: line) + } + } + + public func request( + route: ServerRoute, + file: StaticString = #file, + line: UInt = #line + ) async throws -> (Data, URLResponse) { + do { + let (data, response) = try await self.request(route) + #if DEBUG + print( + """ + API: route: \(route), \ + status: \((response as? HTTPURLResponse)?.statusCode ?? 0), \ + receive data: \(String(decoding: data, as: UTF8.self)) + """ + ) + #endif + return (data, response) + } catch { + throw ApiError(error: error, file: file, line: line) + } } public func request( @@ -80,25 +100,13 @@ public struct ApiClient { as: A.Type, file: StaticString = #file, line: UInt = #line - ) -> Effect { - self.request(route) - .handleEvents( - receiveOutput: { - #if DEBUG - print( - """ - API: route: \(route), \ - status: \(($0.response as? HTTPURLResponse)?.statusCode ?? 0), \ - receive data: \(String(decoding: $0.data, as: UTF8.self)) - """ - ) - #endif - } - ) - .map { data, _ in data } - .apiDecode(as: A.self, file: file, line: line) - .print("API") - .eraseToEffect() + ) async throws -> A { + let (data, _) = try await self.request(route: route, file: file, line: line) + do { + return try apiDecode(A.self, from: data) + } catch { + throw ApiError(error: error, file: file, line: line) + } } public struct LeaderboardEnvelope: Codable, Equatable { @@ -119,51 +127,43 @@ public struct ApiClient { import XCTestDynamicOverlay extension ApiClient { - public static let failing = Self( - apiRequest: { route in .failing("\(Self.self).apiRequest(\(route)) is unimplemented") }, - authenticate: { _ in .failing("\(Self.self).authenticate is unimplemented") }, - baseUrl: { - XCTFail("\(Self.self).baseUrl is unimplemented") - return URL(string: "/")! - }, - currentPlayer: { - XCTFail("\(Self.self).currentPlayer is unimplemented") - return nil - }, - logout: { .failing("\(Self.self).logout is unimplemented") }, - refreshCurrentPlayer: { .failing("\(Self.self).refreshCurrentPlayer is unimplemented") }, - request: { route in .failing("\(Self.self).request(\(route)) is unimplemented") }, - setBaseUrl: { _ in .failing("ApiClient.setBaseUrl is unimplemented") } + public static let unimplemented = Self( + apiRequest: XCTUnimplemented("\(Self.self).apiRequest"), + authenticate: XCTUnimplemented("\(Self.self).authenticate"), + baseUrl: XCTUnimplemented("\(Self.self).baseUrl", placeholder: URL(string: "/")!), + currentPlayer: XCTUnimplemented("\(Self.self).currentPlayer"), + logout: XCTUnimplemented("\(Self.self).logout"), + refreshCurrentPlayer: XCTUnimplemented("\(Self.self).refreshCurrentPlayer"), + request: XCTUnimplemented("\(Self.self).request"), + setBaseUrl: XCTUnimplemented("\(Self.self).setBaseUrl") ) public mutating func override( route matchingRoute: ServerRoute.Api.Route, - withResponse response: Effect<(data: Data, response: URLResponse), URLError> + withResponse response: @escaping @Sendable () async throws -> (Data, URLResponse) ) { let fulfill = expectation(description: "route") - self.apiRequest = { [self] route in + self.apiRequest = { @Sendable [self] route in if route == matchingRoute { fulfill() - return response + return try await response() } else { - return self.apiRequest(route) + return try await self.apiRequest(route) } } } public mutating func override( routeCase matchingRoute: CasePath, - withResponse response: @escaping (Value) -> Effect< - (data: Data, response: URLResponse), URLError - > + withResponse response: @escaping @Sendable (Value) async throws -> (Data, URLResponse) ) { let fulfill = expectation(description: "route") - self.apiRequest = { [self] route in + self.apiRequest = { @Sendable [self] route in if let value = matchingRoute.extract(from: route) { fulfill() - return response(value) + return try await response(value) } else { - return self.apiRequest(route) + return try await self.apiRequest(route) } } } @@ -172,14 +172,14 @@ public struct ApiClient { extension ApiClient { public static let noop = Self( - apiRequest: { _ in .none }, - authenticate: { _ in .none }, + apiRequest: { _ in try await Task.never() }, + authenticate: { _ in try await Task.never() }, baseUrl: { URL(string: "/")! }, currentPlayer: { nil }, - logout: { .none }, - refreshCurrentPlayer: { .none }, - request: { _ in .none }, - setBaseUrl: { _ in .none } + logout: {}, + refreshCurrentPlayer: { try await Task.never() }, + request: { _ in try await Task.never() }, + setBaseUrl: { _ in } ) } diff --git a/Sources/ApiClient/Helpers.swift b/Sources/ApiClient/Helpers.swift index 5c96c0c3..cb4442cc 100644 --- a/Sources/ApiClient/Helpers.swift +++ b/Sources/ApiClient/Helpers.swift @@ -2,30 +2,21 @@ import ComposableArchitecture import Foundation - extension Effect where Output == (data: Data, response: URLResponse), Failure == URLError { - public static func ok( - _ value: A, - encoder: JSONEncoder = .init() - ) -> Self { - .init( - value: ( - try! encoder.encode(value), - HTTPURLResponse( - url: URL(string: "/")!, statusCode: 200, httpVersion: nil, headerFields: nil)! - ) - ) - } + public func OK( + _ value: A, encoder: JSONEncoder = .init() + ) async throws -> (Data, URLResponse) { + ( + try encoder.encode(value), + HTTPURLResponse( + url: URL(string: "/")!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } - public static func ok( - _ jsonObject: Any - ) -> Self { - .init( - value: ( - try! JSONSerialization.data(withJSONObject: jsonObject, options: []), - HTTPURLResponse( - url: URL(string: "/")!, statusCode: 200, httpVersion: nil, headerFields: nil)! - ) - ) - } + public func OK(_ jsonObject: Any) async throws -> (Data, URLResponse) { + ( + try JSONSerialization.data(withJSONObject: jsonObject, options: []), + HTTPURLResponse( + url: URL(string: "/")!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) } #endif diff --git a/Sources/ApiClientLive/Live.swift b/Sources/ApiClientLive/Live.swift index dc44b74b..79d33601 100644 --- a/Sources/ApiClientLive/Live.swift +++ b/Sources/ApiClientLive/Live.swift @@ -4,23 +4,21 @@ import ComposableArchitecture import Foundation import ServerRouter import SharedModels +import TcaHelpers -extension ApiClient { - private static let baseUrlKey = "co.pointfree.isowords.apiClient.baseUrl" - private static let currentUserEnvelopeKey = "co.pointfree.isowords.apiClient.currentUserEnvelope" +private let baseUrlKey = "co.pointfree.isowords.apiClient.baseUrl" +private let currentUserEnvelopeKey = "co.pointfree.isowords.apiClient.currentUserEnvelope" +extension ApiClient { public static func live( baseUrl defaultBaseUrl: URL = URL(string: "http://localhost:9876")!, sha256: @escaping (Data) -> Data ) -> Self { + #if DEBUG - var baseUrl = UserDefaults.standard.url(forKey: baseUrlKey) ?? defaultBaseUrl { - didSet { - UserDefaults.standard.set(baseUrl, forKey: baseUrlKey) - } - } + let baseUrl = UserDefaults.standard.url(forKey: baseUrlKey) ?? defaultBaseUrl #else - var baseUrl = URL(string: "https://www.isowords.xyz")! + let baseUrl = URL(string: "https://www.isowords.xyz")! #endif let router = ServerRouter( @@ -31,29 +29,45 @@ extension ApiClient { sha256: sha256 ) - var currentPlayer = UserDefaults.standard.data(forKey: currentUserEnvelopeKey) - .flatMap({ try? decoder.decode(CurrentPlayerEnvelope.self, from: $0) }) - { - didSet { - UserDefaults.standard.set( - currentPlayer.flatMap { try? encoder.encode($0) }, - forKey: currentUserEnvelopeKey + actor Session { + nonisolated let baseUrl: Isolated + nonisolated let currentPlayer: Isolated + private let router: ServerRouter + + init(baseUrl: URL, router: ServerRouter) { + self.baseUrl = Isolated( + baseUrl, + didSet: { _, newValue in + UserDefaults.standard.set(newValue, forKey: baseUrlKey) + } + ) + self.router = router + self.currentPlayer = Isolated( + UserDefaults.standard.data(forKey: currentUserEnvelopeKey) + .flatMap({ try? decoder.decode(CurrentPlayerEnvelope.self, from: $0) }), + didSet: { _, newValue in + UserDefaults.standard.set( + newValue.flatMap { try? encoder.encode($0) }, + forKey: currentUserEnvelopeKey + ) + } ) } - } - return Self( - apiRequest: { route in - ApiClientLive.apiRequest( - accessToken: currentPlayer?.player.accessToken, - baseUrl: baseUrl, + func apiRequest(route: ServerRoute.Api.Route) async throws -> (Data, URLResponse) { + try await ApiClientLive.apiRequest( + accessToken: self.currentPlayer.value?.player.accessToken, + baseUrl: self.baseUrl.value, route: route, - router: router + router: self.router ) - }, - authenticate: { request in - return ApiClientLive.request( - baseUrl: baseUrl, + } + + func authenticate(request: ServerRoute.AuthenticateRequest) async throws + -> CurrentPlayerEnvelope + { + let (data, _) = try await ApiClientLive.request( + baseUrl: self.baseUrl.value, route: .authenticate( .init( deviceId: request.deviceId, @@ -64,46 +78,55 @@ extension ApiClient { ), router: router ) - .map { data, _ in data } - .apiDecode(as: CurrentPlayerEnvelope.self) - .handleEvents( - receiveOutput: { newPlayer in - DispatchQueue.main.async { currentPlayer = newPlayer } - } - ) - .eraseToEffect() - }, - baseUrl: { baseUrl }, - currentPlayer: { currentPlayer }, - logout: { - .fireAndForget { currentPlayer = nil } - }, - refreshCurrentPlayer: { - ApiClientLive.apiRequest( - accessToken: currentPlayer?.player.accessToken, - baseUrl: baseUrl, + let currentPlayer = try apiDecode(CurrentPlayerEnvelope.self, from: data) + self.currentPlayer.value = currentPlayer + return currentPlayer + } + + func logout() { + self.currentPlayer.value = nil + } + + func refreshCurrentPlayer() async throws -> CurrentPlayerEnvelope { + let (data, _) = try await ApiClientLive.apiRequest( + accessToken: self.currentPlayer.value?.player.accessToken, + baseUrl: self.baseUrl.value, route: .currentPlayer, - router: router - ) - .map { data, _ in data } - .apiDecode(as: CurrentPlayerEnvelope.self) - .handleEvents( - receiveOutput: { newPlayer in - DispatchQueue.main.async { currentPlayer = newPlayer } - } + router: self.router ) - .eraseToEffect() - }, - request: { route in - ApiClientLive.request( - baseUrl: baseUrl, + let currentPlayer = try apiDecode(CurrentPlayerEnvelope.self, from: data) + self.currentPlayer.value = currentPlayer + return currentPlayer + } + + func request(route: ServerRoute) async throws -> (Data, URLResponse) { + try await ApiClientLive.request( + baseUrl: self.baseUrl.value, route: route, - router: router + router: self.router ) - }, - setBaseUrl: { url in - .fireAndForget { baseUrl = url } } + + func setBaseUrl(_ url: URL) { + self.baseUrl.value = url + } + + fileprivate func setCurrentPlayer(_ player: CurrentPlayerEnvelope) { + self.currentPlayer.value = player + } + } + + let session = Session(baseUrl: baseUrl, router: router) + + return Self( + apiRequest: { try await session.apiRequest(route: $0) }, + authenticate: { try await session.authenticate(request: $0) }, + baseUrl: { session.baseUrl.value }, + currentPlayer: { session.currentPlayer.value }, + logout: { await session.logout() }, + refreshCurrentPlayer: { try await session.refreshCurrentPlayer() }, + request: { try await session.request(route: $0) }, + setBaseUrl: { await session.setBaseUrl($0) } ) } } @@ -112,15 +135,15 @@ private func request( baseUrl: URL, route: ServerRoute, router: ServerRouter -) -> Effect<(data: Data, response: URLResponse), URLError> { - Deferred { () -> Effect<(data: Data, response: URLResponse), URLError> in - guard var request = try? router.baseURL(baseUrl.absoluteString).request(for: route) - else { return .init(error: URLError(.badURL)) } - request.setHeaders() - return URLSession.shared.dataTaskPublisher(for: request) - .eraseToEffect() +) async throws -> (Data, URLResponse) { + guard var request = try? router.baseURL(baseUrl.absoluteString).request(for: route) + else { throw URLError(.badURL) } + request.setHeaders() + if #available(iOS 15.0, *) { + return try await URLSession.shared.data(for: request) + } else { + fatalError() } - .eraseToEffect() } private func apiRequest( @@ -128,25 +151,22 @@ private func apiRequest( baseUrl: URL, route: ServerRoute.Api.Route, router: ServerRouter -) -> Effect<(data: Data, response: URLResponse), URLError> { - - return Deferred { () -> Effect<(data: Data, response: URLResponse), URLError> in - guard let accessToken = accessToken - else { return .init(error: .init(.userAuthenticationRequired)) } - - return request( - baseUrl: baseUrl, - route: .api( - .init( - accessToken: accessToken, - isDebug: isDebug, - route: route - ) - ), - router: router - ) - } - .eraseToEffect() +) async throws -> (Data, URLResponse) { + + guard let accessToken = accessToken + else { throw URLError(.userAuthenticationRequired) } + + return try await request( + baseUrl: baseUrl, + route: .api( + .init( + accessToken: accessToken, + isDebug: isDebug, + route: route + ) + ), + router: router + ) } #if DEBUG diff --git a/Sources/AppFeature/AppDelegate.swift b/Sources/AppFeature/AppDelegate.swift index afaac797..61ac23fa 100644 --- a/Sources/AppFeature/AppDelegate.swift +++ b/Sources/AppFeature/AppDelegate.swift @@ -11,12 +11,13 @@ import SharedModels import TcaHelpers import UIKit import UserNotifications +import XCTestDynamicOverlay public enum AppDelegateAction: Equatable { case didFinishLaunching - case didRegisterForRemoteNotifications(Result) + case didRegisterForRemoteNotifications(TaskResult) case userNotifications(UserNotificationClient.DelegateEvent) - case userSettingsLoaded(Result) + case userSettingsLoaded(TaskResult) } struct AppDelegateEnvironment { @@ -28,21 +29,21 @@ struct AppDelegateEnvironment { var fileClient: FileClient var mainQueue: AnySchedulerOf var remoteNotifications: RemoteNotificationsClient - var setUserInterfaceStyle: (UIUserInterfaceStyle) -> Effect + var setUserInterfaceStyle: @Sendable (UIUserInterfaceStyle) async -> Void var userNotifications: UserNotificationClient #if DEBUG - static let failing = Self( - apiClient: .failing, - audioPlayer: .failing, - backgroundQueue: .failing("backgroundQueue"), - build: .failing, - dictionary: .failing, - fileClient: .failing, - mainQueue: .failing("mainQueue"), - remoteNotifications: .failing, - setUserInterfaceStyle: { _ in .failing("setUserInterfaceStyle") }, - userNotifications: .failing + static let unimplemented = Self( + apiClient: .unimplemented, + audioPlayer: .unimplemented, + backgroundQueue: .unimplemented("backgroundQueue"), + build: .unimplemented, + dictionary: .unimplemented, + fileClient: .unimplemented, + mainQueue: .unimplemented("mainQueue"), + remoteNotifications: .unimplemented, + setUserInterfaceStyle: XCTUnimplemented("\(Self.self).setUserInterfaceStyle"), + userNotifications: .unimplemented ) #endif } @@ -52,69 +53,67 @@ let appDelegateReducer = Reducer< > { state, action, environment in switch action { case .didFinishLaunching: - return .merge( - // Set notifications delegate - environment.userNotifications.delegate - .map(AppDelegateAction.userNotifications), - - environment.userNotifications.getNotificationSettings - .receive(on: environment.mainQueue) - .flatMap { settings in - [.notDetermined, .provisional].contains(settings.authorizationStatus) - ? environment.userNotifications.requestAuthorization(.provisional) - : settings.authorizationStatus == .authorized - ? environment.userNotifications.requestAuthorization([.alert, .sound]) - : .none + return .run { send in + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + for await event in environment.userNotifications.delegate() { + await send(.userNotifications(event)) + } } - .ignoreFailure() - .flatMap { successful in - successful - ? Effect.registerForRemoteNotifications( - remoteNotifications: environment.remoteNotifications, - scheduler: environment.mainQueue, - userNotifications: environment.userNotifications - ) - : .none + + group.addTask { + let settings = await environment.userNotifications.getNotificationSettings() + switch settings.authorizationStatus { + case .authorized: + guard + try await environment.userNotifications.requestAuthorization([.alert, .sound]) + else { return } + case .notDetermined, .provisional: + guard try await environment.userNotifications.requestAuthorization(.provisional) + else { return } + default: + return + } + await environment.remoteNotifications.register() } - .eraseToEffect() - .fireAndForget(), - // Preload dictionary - Effect - .catching { try environment.dictionary.load(.en) } - .subscribe(on: environment.backgroundQueue) - .fireAndForget(), + group.addTask { + _ = try environment.dictionary.load(.en) + } - .concatenate( - environment.audioPlayer.load(AudioPlayerClient.Sound.allCases) - .fireAndForget(), + group.addTask { + await environment.audioPlayer.load(AudioPlayerClient.Sound.allCases) + } - environment.fileClient.loadUserSettings() - .map(AppDelegateAction.userSettingsLoaded) - ) - ) + group.addTask { + await send( + .userSettingsLoaded( + TaskResult { try await environment.fileClient.loadUserSettings() } + ) + ) + } + } + } case .didRegisterForRemoteNotifications(.failure): return .none case let .didRegisterForRemoteNotifications(.success(tokenData)): let token = tokenData.map { String(format: "%02.2hhx", $0) }.joined() - return environment.userNotifications.getNotificationSettings - .flatMap { settings in - environment.apiClient.apiRequest( - route: .push( - .register( - .init( - authorizationStatus: .init(rawValue: settings.authorizationStatus.rawValue), - build: environment.build.number(), - token: token - ) + return .fireAndForget { + let settings = await environment.userNotifications.getNotificationSettings() + _ = try await environment.apiClient.apiRequest( + route: .push( + .register( + .init( + authorizationStatus: .init(rawValue: settings.authorizationStatus.rawValue), + build: environment.build.number(), + token: token ) ) ) - } - .receive(on: environment.mainQueue) - .fireAndForget() + ) + } case let .userNotifications(.willPresentNotification(_, completionHandler)): return .fireAndForget { @@ -125,25 +124,17 @@ let appDelegateReducer = Reducer< return .none case let .userSettingsLoaded(result): - state = (try? result.get()) ?? state - return .merge( - environment.audioPlayer.setGlobalVolumeForSoundEffects( - state.soundEffectsVolume - ) - .fireAndForget(), - - environment.audioPlayer.setGlobalVolumeForSoundEffects( + state = (try? result.value) ?? state + return .fireAndForget { [state] in + async let setSoundEffects: Void = + await environment.audioPlayer.setGlobalVolumeForSoundEffects(state.soundEffectsVolume) + async let setMusic: Void = await environment.audioPlayer.setGlobalVolumeForMusic( environment.audioPlayer.secondaryAudioShouldBeSilencedHint() ? 0 : state.musicVolume ) - .fireAndForget(), - - environment.setUserInterfaceStyle(state.colorScheme.userInterfaceStyle) - // NB: This is necessary because UIKit needs at least one tick of the run loop before we - // can set the user interface style. - .subscribe(on: environment.mainQueue) - .fireAndForget() - ) + async let setUI: Void = + await environment.setUserInterfaceStyle(state.colorScheme.userInterfaceStyle) + } } } diff --git a/Sources/AppFeature/AppEnvironment.swift b/Sources/AppFeature/AppEnvironment.swift index b8fcf78d..8678cd46 100644 --- a/Sources/AppFeature/AppEnvironment.swift +++ b/Sources/AppFeature/AppEnvironment.swift @@ -36,7 +36,7 @@ public struct AppEnvironment { public var mainRunLoop: AnySchedulerOf public var remoteNotifications: RemoteNotificationsClient public var serverConfig: ServerConfigClient - public var setUserInterfaceStyle: (UIUserInterfaceStyle) -> Effect + public var setUserInterfaceStyle: @Sendable (UIUserInterfaceStyle) async -> Void public var storeKit: StoreKitClient public var timeZone: () -> TimeZone public var userDefaults: UserDefaultsClient @@ -59,7 +59,7 @@ public struct AppEnvironment { mainRunLoop: AnySchedulerOf, remoteNotifications: RemoteNotificationsClient, serverConfig: ServerConfigClient, - setUserInterfaceStyle: @escaping (UIUserInterfaceStyle) -> Effect, + setUserInterfaceStyle: @escaping @Sendable (UIUserInterfaceStyle) async -> Void, storeKit: StoreKitClient, timeZone: @escaping () -> TimeZone, userDefaults: UserDefaultsClient, @@ -89,33 +89,28 @@ public struct AppEnvironment { } #if DEBUG - public static let failing = Self( - apiClient: .failing, - applicationClient: .failing, - audioPlayer: .failing, - backgroundQueue: .failing("backgroundQueue"), - build: .failing, - database: .failing, - deviceId: .failing, - dictionary: .failing, - feedbackGenerator: .failing, - fileClient: .failing, - gameCenter: .failing, - lowPowerMode: .failing, - mainQueue: .failing("mainQueue"), - mainRunLoop: .failing("mainRunLoop"), - remoteNotifications: .failing, - serverConfig: .failing, - setUserInterfaceStyle: { _ in - .failing("\(Self.self).setUserInterfaceStyle is unimplemented") - }, - storeKit: .failing, - timeZone: { - XCTFail("\(Self.self).timeZone is unimplemented") - return TimeZone(secondsFromGMT: 0)! - }, - userDefaults: .failing, - userNotifications: .failing + public static let unimplemented = Self( + apiClient: .unimplemented, + applicationClient: .unimplemented, + audioPlayer: .unimplemented, + backgroundQueue: .unimplemented("backgroundQueue"), + build: .unimplemented, + database: .unimplemented, + deviceId: .unimplemented, + dictionary: .unimplemented, + feedbackGenerator: .unimplemented, + fileClient: .unimplemented, + gameCenter: .unimplemented, + lowPowerMode: .unimplemented, + mainQueue: .unimplemented("mainQueue"), + mainRunLoop: .unimplemented("mainRunLoop"), + remoteNotifications: .unimplemented, + serverConfig: .unimplemented, + setUserInterfaceStyle: XCTUnimplemented("\(Self.self).setUserInterfaceStyle"), + storeKit: .unimplemented, + timeZone: XCTUnimplemented("\(Self.self).timeZone"), + userDefaults: .unimplemented, + userNotifications: .unimplemented ) public static let noop = Self( @@ -135,7 +130,7 @@ public struct AppEnvironment { mainRunLoop: .immediate, remoteNotifications: .noop, serverConfig: .noop, - setUserInterfaceStyle: { _ in .none }, + setUserInterfaceStyle: { _ in }, storeKit: .noop, timeZone: { .autoupdatingCurrent }, userDefaults: .noop, diff --git a/Sources/AppFeature/AppView.swift b/Sources/AppFeature/AppView.swift index e6afe511..8593442e 100644 --- a/Sources/AppFeature/AppView.swift +++ b/Sources/AppFeature/AppView.swift @@ -6,6 +6,7 @@ import ComposableUserNotifications import CubeCore import GameFeature import HomeFeature +import NotificationHelpers import OnboardingFeature import ServerConfig import ServerRouter @@ -72,8 +73,8 @@ public enum AppAction: Equatable { case home(HomeAction) case onboarding(OnboardingAction) case paymentTransaction(StoreKitClient.PaymentTransactionObserverEvent) - case savedGamesLoaded(Result) - case verifyReceiptResponse(Result) + case savedGamesLoaded(TaskResult) + case verifyReceiptResponse(TaskResult) } extension AppEnvironment { @@ -205,10 +206,9 @@ extension Reducer where State == AppState, Action == AppAction, Environment == A } .onChange(of: \.home.savedGames) { savedGames, _, action, environment in if case .savedGamesLoaded(.success) = action { return .none } - return environment.fileClient - .saveGames(games: savedGames, on: environment.backgroundQueue) - .receive(on: environment.mainQueue) - .fireAndForget() + return .fireAndForget { + try await environment.fileClient.save(games: savedGames) + } } } } @@ -220,52 +220,52 @@ let appReducerCore = Reducer { state, actio state.onboarding = .init(presentationStyle: .firstLaunch) } - return .merge( - environment.database.migrate.fireAndForget(), - environment.fileClient.loadSavedGames().map(AppAction.savedGamesLoaded), - - environment.userDefaults.installationTime <= 0 - ? environment.userDefaults.setInstallationTime( + return .run { send in + async let migrate: Void = environment.database.migrate() + if environment.userDefaults.installationTime <= 0 { + async let setInstallationTime: Void = environment.userDefaults.setInstallationTime( environment.mainRunLoop.now.date.timeIntervalSinceReferenceDate ) - .fireAndForget() - : .none - ) + } + await send( + .savedGamesLoaded( + TaskResult { try await environment.fileClient.loadSavedGames() } + ) + ) + } case let .appDelegate(.userNotifications(.didReceiveResponse(response, completionHandler))): - let effect = Effect.fireAndForget(completionHandler) - - guard + if let data = try? JSONSerialization .data(withJSONObject: response.notification.request.content.userInfo), let pushNotificationContent = try? JSONDecoder() .decode(PushNotificationContent.self, from: data) - else { return effect } - - switch pushNotificationContent { - case .dailyChallengeEndsSoon: - if let inProgressGame = state.home.savedGames.dailyChallengeUnlimited { - state.currentGame = GameFeatureState( - game: GameState(inProgressGame: inProgressGame), - settings: state.home.settings - ) - } else { - // TODO: load/retry - } + { + switch pushNotificationContent { + case .dailyChallengeEndsSoon: + if let inProgressGame = state.home.savedGames.dailyChallengeUnlimited { + state.currentGame = GameFeatureState( + game: GameState(inProgressGame: inProgressGame), + settings: state.home.settings + ) + } else { + // TODO: load/retry + } - case .dailyChallengeReport: - state.game = nil - state.home.route = .dailyChallenge(.init()) + case .dailyChallengeReport: + state.game = nil + state.home.route = .dailyChallenge(.init()) + } } - return effect + return .fireAndForget { completionHandler() } case .appDelegate: return .none case .currentGame(.game(.endGameButtonTapped)), - .currentGame(.game(.gameOver(.onAppear))): + .currentGame(.game(.gameOver(.task))): switch (state.game?.gameContext, state.game?.gameMode) { case (.dailyChallenge, .unlimited): @@ -301,19 +301,17 @@ let appReducerCore = Reducer { state, actio case let .currentGame(.game(.activeGames(.turnBasedGameTapped(matchId)))), let .home(.activeGames(.turnBasedGameTapped(matchId))): - return environment.gameCenter.turnBasedMatch.load(matchId) - .ignoreFailure() - .map { - .gameCenter( - .listener( - .turnBased( - .receivedTurnEventForMatch($0, didBecomeActive: true) - ) - ) + return .run { send in + do { + let match = try await environment.gameCenter.turnBasedMatch.load(matchId) + await send( + .gameCenter( + .listener(.turnBased(.receivedTurnEventForMatch(match, didBecomeActive: true))) + ), + animation: .default ) - } - .receive(on: environment.mainQueue.animation()) - .eraseToEffect() + } catch {} + } case .currentGame(.game(.exitButtonTapped)), .currentGame(.game(.gameOver(.delegate(.close)))): @@ -359,10 +357,9 @@ let appReducerCore = Reducer { state, actio != state.home.savedGames.dailyChallengeUnlimited?.dailyChallengeId { state.home.savedGames.dailyChallengeUnlimited = nil - return environment.fileClient - .saveGames(games: state.home.savedGames, on: environment.backgroundQueue) - .receive(on: environment.mainQueue) - .fireAndForget() + return .fireAndForget { [savedGames = state.home.savedGames] in + try await environment.fileClient.save(games: savedGames) + } } return .none @@ -371,18 +368,13 @@ let appReducerCore = Reducer { state, actio return .none case .didChangeScenePhase(.active): - return .merge( - Effect.registerForRemoteNotifications( + return .fireAndForget { + async let register: Void = registerForRemoteNotificationsAsync( remoteNotifications: environment.remoteNotifications, - scheduler: environment.mainQueue, userNotifications: environment.userNotifications ) - .fireAndForget(), - - environment.serverConfig.refresh() - .receive(on: environment.mainQueue) - .fireAndForget() - ) + async let refresh = environment.serverConfig.refresh() + } case .didChangeScenePhase: return .none diff --git a/Sources/AppFeature/GameCenterCore.swift b/Sources/AppFeature/GameCenterCore.swift index 6d3e3780..eabddc4b 100644 --- a/Sources/AppFeature/GameCenterCore.swift +++ b/Sources/AppFeature/GameCenterCore.swift @@ -1,7 +1,6 @@ import ClientModels import ComposableArchitecture import ComposableGameCenter -import ComposableGameCenterHelpers import GameFeature import GameKit import GameOverFeature @@ -11,8 +10,7 @@ import SharedModels public enum GameCenterAction: Equatable { case listener(LocalPlayerClient.ListenerEvent) - case rematchResponse(Result) - case turnBasedMatchReloaded(Result) + case rematchResponse(TaskResult) } extension Reducer where State == AppState, Action == AppAction, Environment == AppEnvironment { @@ -45,25 +43,22 @@ extension Reducer where State == AppState, Action == AppAction, Environment == A game: game, settings: state.home.settings ) - return .merge( - environment.gameCenter.turnBasedMatchmakerViewController.dismiss - .fireAndForget(), - environment.gameCenter.turnBasedMatch - .saveCurrentTurn( - match.matchId, - Data( - turnBasedMatchData: .init( - context: context, - gameState: game, - playerId: environment.apiClient.currentPlayer()?.player.id - ) + return .fireAndForget { + await environment.gameCenter.turnBasedMatchmakerViewController.dismiss() + try await environment.gameCenter.turnBasedMatch.saveCurrentTurn( + match.matchId, + Data( + turnBasedMatchData: .init( + context: context, + gameState: game, + playerId: environment.apiClient.currentPlayer()?.player.id ) ) - .fireAndForget() - ) + ) + } } - guard var turnBasedMatchData = matchData.turnBasedMatchData else { + guard let turnBasedMatchData = matchData.turnBasedMatchData else { return .none } @@ -91,19 +86,17 @@ extension Reducer where State == AppState, Action == AppAction, Environment == A game: gameState, settings: state.home.settings ) - turnBasedMatchData.metadata.lastOpenedAt = environment.mainRunLoop.now.date - return .merge( - environment.gameCenter.turnBasedMatchmakerViewController.dismiss - .fireAndForget(), - - gameState.isYourTurn - ? environment.gameCenter.turnBasedMatch.saveCurrentTurn( + return .fireAndForget { [isYourTurn = gameState.isYourTurn, turnBasedMatchData] in + await environment.gameCenter.turnBasedMatchmakerViewController.dismiss() + if isYourTurn { + var turnBasedMatchData = turnBasedMatchData + turnBasedMatchData.metadata.lastOpenedAt = environment.mainRunLoop.now.date + try await environment.gameCenter.turnBasedMatch.saveCurrentTurn( match.matchId, Data(turnBasedMatchData: turnBasedMatchData) ) - .fireAndForget() - : .none - ) + } + } } let context = TurnBasedContext( @@ -119,23 +112,21 @@ extension Reducer where State == AppState, Action == AppAction, Environment == A lastTurnDate > environment.mainRunLoop.now.date.addingTimeInterval(-60) else { return .none } - return environment.gameCenter - .showNotificationBanner(.init(title: match.message, message: nil)) - .fireAndForget() + return .fireAndForget { + await environment.gameCenter.showNotificationBanner( + .init(title: match.message, message: nil) + ) + } } switch action { case .appDelegate(.didFinishLaunching): - return environment.gameCenter.localPlayer.authenticate - .map { $0 == nil } - .removeDuplicates() - .flatMap { - $0 - ? environment.gameCenter.localPlayer.listener.map { .gameCenter(.listener($0)) } - .cancellable(id: ListenerId(), cancelInFlight: true) - : .cancel(id: ListenerId()) + return .run { send in + try await environment.gameCenter.localPlayer.authenticate() + for await event in environment.gameCenter.localPlayer.listener() { + await send(.gameCenter(.listener(event))) } - .eraseToEffect() + } case .currentGame(.game(.gameOver(.rematchButtonTapped))): guard @@ -145,21 +136,24 @@ extension Reducer where State == AppState, Action == AppAction, Environment == A state.game = nil - return environment.gameCenter.turnBasedMatch - .rematch(turnBasedMatch.match.matchId) - .receive(on: environment.mainQueue) - .mapError { $0 as NSError } - .catchToEffect { .gameCenter(.rematchResponse($0)) } + return .task { + await .gameCenter( + .rematchResponse( + TaskResult { + try await environment.gameCenter.turnBasedMatch.rematch( + turnBasedMatch.match.matchId + ) + } + ) + ) + } case let .gameCenter(.listener(.turnBased(.matchEnded(match)))): - guard state.game?.turnBasedContext?.match.matchId == match.matchId + guard + state.game?.turnBasedContext?.match.matchId == match.matchId, + let turnBasedMatchData = match.matchData?.turnBasedMatchData else { return .none } - guard let turnBasedMatchData = match.matchData?.turnBasedMatchData - else { - return .none - } - let newGame = GameState( gameCurrentTime: environment.mainRunLoop.now.date, localPlayer: environment.gameCenter.localPlayer.localPlayer(), @@ -168,27 +162,29 @@ extension Reducer where State == AppState, Action == AppAction, Environment == A ) state.game = newGame - return environment.database - .saveGame(.init(gameState: newGame)) - .fireAndForget() + return .fireAndForget { + try await environment.database.saveGame(.init(gameState: newGame)) + } case let .gameCenter( .listener(.turnBased(.receivedTurnEventForMatch(match, didBecomeActive)))): return handleTurnBasedMatch(match, didBecomeActive: didBecomeActive) case let .gameCenter(.listener(.turnBased(.wantsToQuitMatch(match)))): - return environment.gameCenter.turnBasedMatch - .endMatchInTurn( + return .fireAndForget { + try await environment.gameCenter.turnBasedMatch.endMatchInTurn( .init( for: match.matchId, matchData: match.matchData ?? Data(), localPlayerId: environment.gameCenter.localPlayer.localPlayer().gamePlayerId, localPlayerMatchOutcome: .quit, - message: - "\(environment.gameCenter.localPlayer.localPlayer().displayName) forfeited the match." + message: """ + \(environment.gameCenter.localPlayer.localPlayer().displayName) \ + forfeited the match. + """ ) ) - .fireAndForget() + } case .gameCenter(.listener): return .none @@ -197,23 +193,21 @@ extension Reducer where State == AppState, Action == AppAction, Environment == A let .home(.multiplayer(.pastGames(.pastGame(_, .delegate(.openMatch(turnBasedMatch)))))): return handleTurnBasedMatch(turnBasedMatch, didBecomeActive: true) - case let .gameCenter(.turnBasedMatchReloaded(.success(turnBasedMatch))): - if state.game?.turnBasedContext?.match.matchId == turnBasedMatch.matchId { - state.game?.turnBasedContext?.match = turnBasedMatch - } - return .none - case let .home(.activeGames(.turnBasedGameMenuItemTapped(.rematch(matchId)))): - return environment.gameCenter.turnBasedMatch.rematch(matchId) - .receive(on: environment.mainQueue) - .mapError { $0 as NSError } - .catchToEffect { .gameCenter(.rematchResponse($0)) } + return .task { + await .gameCenter( + .rematchResponse( + TaskResult { + try await environment.gameCenter.turnBasedMatch.rematch(matchId) + } + ) + ) + } default: return .none } - }) + } + ) } } - -private struct ListenerId: Hashable {} diff --git a/Sources/AppFeature/StoreKitCore.swift b/Sources/AppFeature/StoreKitCore.swift index 67d27941..cbaecd70 100644 --- a/Sources/AppFeature/StoreKitCore.swift +++ b/Sources/AppFeature/StoreKitCore.swift @@ -17,66 +17,59 @@ extension Reducer where Action == AppAction, Environment == AppEnvironment { with: Reducer { _, action, environment in switch action { case .appDelegate(.didFinishLaunching): - return environment.storeKit.observer - .map(AppAction.paymentTransaction) - - case let .paymentTransaction(.updatedTransactions(transactions)): - let verifiableTransactions = transactions.filter { $0.transactionState.canBeVerified } - let otherTransactions = transactions.filter { !$0.transactionState.canBeVerified } - - let verifyReceiptEffect: Effect - if verifiableTransactions.isEmpty { - verifyReceiptEffect = .none - } else if let appStoreReceiptURL = environment.storeKit.appStoreReceiptURL(), - let receiptData = try? Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped) - { - verifyReceiptEffect = environment.apiClient.apiRequest( - route: .verifyReceipt(receiptData), - as: VerifyReceiptEnvelope.self - ) - .mapError { $0 as NSError } - .map { ReceiptFinalizationEnvelope(transactions: transactions, verifyEnvelope: $0) } - .catchToEffect(AppAction.verifyReceiptResponse) - } else { - // TODO: what to do if there is no receipt data? - verifyReceiptEffect = .none + return .run { send in + for await event in environment.storeKit.observer() { + await send(.paymentTransaction(event)) + } } - let otherTransactionEffects: [Effect] = otherTransactions.map { - transaction in - switch transaction.transactionState { - case .purchasing: - // TODO: what to do? nothing? - return .none + case let .paymentTransaction(.updatedTransactions(transactions)): + return .run { send in + let verifiableTransactions = transactions.filter { $0.transactionState.canBeVerified } + let otherTransactions = transactions.filter { !$0.transactionState.canBeVerified } - case .purchased, .restored: - return .none + if + !verifiableTransactions.isEmpty, + let appStoreReceiptURL = environment.storeKit.appStoreReceiptURL(), + let receiptData = try? Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped) + { + await send( + .verifyReceiptResponse( + TaskResult { + try await ReceiptFinalizationEnvelope( + transactions: transactions, + verifyEnvelope: environment.apiClient.apiRequest( + route: .verifyReceipt(receiptData), + as: VerifyReceiptEnvelope.self + ) + ) + } + ) + ) + } - case .failed: - return environment.storeKit.finishTransaction(transaction) - .fireAndForget() + for transaction in otherTransactions { + switch transaction.transactionState { + case .failed: + await environment.storeKit.finishTransaction(transaction) - case .deferred: - // TODO: Update UI to show await parent approval - return .none + case .deferred, .purchased, .purchasing, .restored: + return - @unknown default: - return .none + @unknown default: + return + } } } - return .merge([verifyReceiptEffect] + otherTransactionEffects) - case let .verifyReceiptResponse(.success(envelope)): - return .merge( - envelope.transactions - .compactMap { transaction in - envelope.verifyEnvelope.verifiedProductIds - .contains { $0 == transaction.payment.productIdentifier } - ? environment.storeKit.finishTransaction(transaction).fireAndForget() - : nil - } - ) + return .fireAndForget { + for transaction in envelope.transactions + where envelope.verifyEnvelope.verifiedProductIds + .contains(where: { $0 == transaction.payment.productIdentifier }) { + await environment.storeKit.finishTransaction(transaction) + } + } case .verifyReceiptResponse(.failure): return .none @@ -84,6 +77,7 @@ extension Reducer where Action == AppAction, Environment == AppEnvironment { default: return .none } - }) + } + ) } } diff --git a/Sources/AudioPlayerClient/Client.swift b/Sources/AudioPlayerClient/Client.swift index 80268432..4226d770 100644 --- a/Sources/AudioPlayerClient/Client.swift +++ b/Sources/AudioPlayerClient/Client.swift @@ -1,14 +1,14 @@ import ComposableArchitecture public struct AudioPlayerClient { - public var load: ([Sound]) -> Effect - public var loop: (Sound) -> Effect - public var play: (Sound) -> Effect - public var secondaryAudioShouldBeSilencedHint: () -> Bool - public var setGlobalVolumeForMusic: (Float) -> Effect - public var setGlobalVolumeForSoundEffects: (Float) -> Effect - public var setVolume: (Sound, Float) -> Effect - public var stop: (Sound) -> Effect + public var load: @Sendable ([Sound]) async -> Void + public var loop: @Sendable (Sound) async -> Void + public var play: @Sendable (Sound) async -> Void + public var secondaryAudioShouldBeSilencedHint: @Sendable () async -> Bool + public var setGlobalVolumeForMusic: @Sendable (Float) async -> Void + public var setGlobalVolumeForSoundEffects: @Sendable (Float) async -> Void + public var setVolume: @Sendable (Sound, Float) async ->Void + public var stop: @Sendable (Sound) async -> Void public struct Sound: Hashable { public let category: Category @@ -29,8 +29,7 @@ public struct AudioPlayerClient { var client = self client.play = { sound in guard doNotIncludeSounds.contains(sound) - else { return self.play(sound) } - return .none + else { return await self.play(sound) } } return client } @@ -38,14 +37,14 @@ public struct AudioPlayerClient { extension AudioPlayerClient { public static let noop = Self( - load: { _ in .none }, - loop: { _ in .none }, - play: { _ in .none }, + load: { _ in }, + loop: { _ in }, + play: { _ in }, secondaryAudioShouldBeSilencedHint: { false }, - setGlobalVolumeForMusic: { _ in .none }, - setGlobalVolumeForSoundEffects: { _ in .none }, - setVolume: { _, _ in .none }, - stop: { _ in .none } + setGlobalVolumeForMusic: { _ in }, + setGlobalVolumeForSoundEffects: { _ in }, + setVolume: { _, _ in }, + stop: { _ in } ) } @@ -53,22 +52,19 @@ extension AudioPlayerClient { import XCTestDynamicOverlay extension AudioPlayerClient { - public static let failing = Self( - load: { _ in .failing("\(Self.self).load is unimplemented") }, - loop: { _ in .failing("\(Self.self).loop is unimplemented") }, - play: { _ in .failing("\(Self.self).play is unimplemented") }, - secondaryAudioShouldBeSilencedHint: { - XCTFail("\(Self.self).secondaryAudioShouldBeSilencedHint is unimplemented") - return false - }, - setGlobalVolumeForMusic: { _ in - .failing("\(Self.self).setGlobalVolumeForMusic is unimplemented") - }, - setGlobalVolumeForSoundEffects: { _ in - .failing("\(Self.self).setGlobalVolumeForSoundEffects is unimplemented") - }, - setVolume: { _, _ in .failing("\(Self.self).setVolume is unimplemented") }, - stop: { _ in .failing("\(Self.self).stop is unimplemented") } + public static let unimplemented = Self( + load: XCTUnimplemented("\(Self.self).load"), + loop: XCTUnimplemented("\(Self.self).loop"), + play: XCTUnimplemented("\(Self.self).play"), + secondaryAudioShouldBeSilencedHint: XCTUnimplemented( + "\(Self.self).secondaryAudioShouldBeSilencedHint", placeholder: false + ), + setGlobalVolumeForMusic: XCTUnimplemented("\(Self.self).setGlobalVolumeForMusic"), + setGlobalVolumeForSoundEffects: XCTUnimplemented( + "\(Self.self).setGlobalVolumeForSoundEffects" + ), + setVolume: XCTUnimplemented("\(Self.self).setVolume"), + stop: XCTUnimplemented("\(Self.self).stop") ) } #endif diff --git a/Sources/AudioPlayerClient/Live.swift b/Sources/AudioPlayerClient/Live.swift index 70466ed4..9a2a2101 100644 --- a/Sources/AudioPlayerClient/Live.swift +++ b/Sources/AudioPlayerClient/Live.swift @@ -1,177 +1,150 @@ import AVFoundation +import ComposableArchitecture extension AudioPlayerClient { public static func live(bundles: [Bundle]) -> Self { - Self( - load: { sounds in - .fireAndForget { - queue.async { - let soundsToLoad = sounds.filter { !files.keys.contains($0) } - - try? AVAudioSession.sharedInstance().setCategory(.ambient) - try? AVAudioSession.sharedInstance().setActive(true, options: []) - for sound in soundsToLoad { - for bundle in bundles { - guard let url = bundle.url(forResource: sound.name, withExtension: "mp3") - else { continue } - files[sound] = AudioPlayer(category: sound.category, url: url) - } - } - guard !files.isEmpty else { return } - try? audioEngine.start() - } - } - }, - loop: { sound in - .fireAndForget { - queue.async { - files[sound]?.play(loop: true) - } - } - }, - play: { sound in - .fireAndForget { - queue.async { - files[sound]?.play() - } - } - }, + let actor = AudioActor(bundles: .init(wrappedValue: bundles)) + return Self( + load: { try? await actor.load(sounds: $0) }, + loop: { try? await actor.play(sound: $0, loop: true) }, + play: { try? await actor.play(sound: $0) }, secondaryAudioShouldBeSilencedHint: { AVAudioSession.sharedInstance().secondaryAudioShouldBeSilencedHint }, - setGlobalVolumeForMusic: { volume in - .fireAndForget { - queue.async { - musicVolume = volume - } - } - }, - setGlobalVolumeForSoundEffects: { volume in - .fireAndForget { - queue.async { - soundEffectsNode.volume = 0.25 * volume - } - } - }, - setVolume: { sound, volume in - .fireAndForget { - queue.async { - files[sound]?.volume = volume - } - } - }, - stop: { sound in - .fireAndForget { - queue.async { - files[sound]?.stop() - } - } - } + setGlobalVolumeForMusic: { await actor.setMusicVolume(to: $0) }, + setGlobalVolumeForSoundEffects: { await actor.setSoundEffectsVolume(to: $0) }, + setVolume: { try? await actor.setVolume(of: $0, to: $1) }, + stop: { try? await actor.stop(sound: $0) } ) } -} -private var files: [AudioPlayerClient.Sound: AudioPlayer] = [:] - -private class AudioPlayer { - enum Source { - case music(AVAudioPlayer) - case soundEffect(AVAudioPlayerNode, AVAudioPCMBuffer) - } + private actor AudioActor { + enum Failure: Error { + case bufferInitializationFailed + case soundNotLoaded(AudioPlayerClient.Sound) + case soundsNotLoaded([AudioPlayerClient.Sound: Error]) + } - let source: Source - var volume: Float = 1 { - didSet { - self.setVolume(self.volume) + enum Player { + case music(AVAudioPlayer) + case soundEffect(AVAudioPlayerNode, AVAudioPCMBuffer) } - } - init?(category: AudioPlayerClient.Sound.Category, url: URL) { - switch category { - case .music: - guard let player = try? AVAudioPlayer(contentsOf: url) - else { return nil } - self.source = .music(player) - - case .soundEffect: - guard - let file = try? AVAudioFile(forReading: url), - let buffer = AVAudioPCMBuffer( - pcmFormat: file.processingFormat, - frameCapacity: AVAudioFrameCount(file.length) - ), - (try? file.read(into: buffer)) != nil - else { return nil } - let node = AVAudioPlayerNode() - audioEngine.attach(node) - audioEngine.connect(node, to: soundEffectsNode, format: nil) - self.source = .soundEffect(node, buffer) + let audioEngine: AVAudioEngine + let bundles: [Bundle] + var musicVolume: Float = 1.0 + var players: [Sound: Player] = [:] + let soundEffectsNode: AVAudioMixerNode + + init(bundles: UncheckedSendable<[Bundle]>) { + let audioEngine = AVAudioEngine() + let soundEffectsNode = AVAudioMixerNode() + audioEngine.attach(soundEffectsNode) + audioEngine.connect(soundEffectsNode, to: audioEngine.mainMixerNode, format: nil) + self.audioEngine = audioEngine + self.bundles = bundles.wrappedValue + self.soundEffectsNode = soundEffectsNode } - } - func play(loop: Bool = false) { - switch self.source { - case let .music(player): - player.currentTime = 0 - player.numberOfLoops = loop ? -1 : 0 - player.volume = musicVolume - player.play() - - case let .soundEffect(node, buffer): - if !audioEngine.isRunning { - guard (try? audioEngine.start()) != nil else { return } - } + func load(sounds: [Sound]) throws { + let sounds = sounds.filter { !self.players.keys.contains($0) } + try AVAudioSession.sharedInstance().setCategory(.ambient) + try AVAudioSession.sharedInstance().setActive(true, options: []) + var errors: [Sound: Error] = [:] + for sound in sounds { + for bundle in self.bundles { + do { + guard let url = bundle.url(forResource: sound.name, withExtension: "mp3") + else { continue } + switch sound.category { + case .music: + self.players[sound] = try .music(AVAudioPlayer(contentsOf: url)) - node.stop() - node.scheduleBuffer( - buffer, - at: nil, - options: loop ? .loops : [], - completionCallbackType: .dataPlayedBack, - completionHandler: nil - ) - node.play(at: nil) + case .soundEffect: + let file = try AVAudioFile(forReading: url) + guard + let buffer = AVAudioPCMBuffer( + pcmFormat: file.processingFormat, + frameCapacity: AVAudioFrameCount(file.length) + ) + else { throw Failure.bufferInitializationFailed } + try file.read(into: buffer) + let node = AVAudioPlayerNode() + audioEngine.attach(node) + audioEngine.connect(node, to: soundEffectsNode, format: nil) + self.players[sound] = .soundEffect(node, buffer) + } + } catch { + errors[sound] = error + } + } + } + guard errors.isEmpty else { throw Failure.soundsNotLoaded(errors) } } - } - private func setVolume(_ volume: Float) { - switch self.source { - case let .music(player): - player.volume = volume + func play(sound: Sound, loop: Bool = false) throws { + guard let player = self.players[sound] else { throw Failure.soundNotLoaded(sound) } + + switch player { + case let .music(player): + player.currentTime = 0 + player.numberOfLoops = loop ? -1 : 0 + player.volume = self.musicVolume + player.play() - case let .soundEffect(node, _): - node.volume = volume + case let .soundEffect(node, buffer): + if !self.audioEngine.isRunning { + try audioEngine.start() + } + + node.scheduleBuffer( + buffer, + at: nil, + options: loop ? .loops : [], + completionCallbackType: .dataPlayedBack, + completionHandler: nil + ) + node.play() + } } - } - func stop() { - switch self.source { - case let .music(player): - player.setVolume(0, fadeDuration: 2.5) - queue.asyncAfter(deadline: .now() + 2.5) { - player.stop() + func stop(sound: Sound) throws { + guard let player = self.players[sound] else { throw Failure.soundNotLoaded(sound) } + + switch player { + case let .music(player): + player.setVolume(0, fadeDuration: 2.5) + Task { + try await Task.sleep(nanoseconds: 2_500 * NSEC_PER_MSEC) + player.stop() + } + + case let .soundEffect(node, _): + node.stop() } + } + + func setVolume(of sound: Sound, to volume: Float) throws { + guard let player = self.players[sound] else { throw Failure.soundNotLoaded(sound) } - case let .soundEffect(node, _): - node.stop() + switch player { + case let .music(player): + player.volume = volume + + case let .soundEffect(node, _): + node.volume = volume + } } - } -} -let audioEngine = AVAudioEngine() -let soundEffectsNode: AVAudioMixerNode = { - let node = AVAudioMixerNode() - audioEngine.attach(node) - audioEngine.connect(node, to: audioEngine.mainMixerNode, format: nil) - return node -}() -private var musicVolume: Float = 1 { - didSet { - files.forEach { _, file in - if case .music = file.source { - file.volume = musicVolume + func setMusicVolume(to volume: Float) { + self.musicVolume = volume + for (sound, _) in self.players where sound.category == .music { + try? self.setVolume(of: sound, to: volume) } } + + func setSoundEffectsVolume(to volume: Float) { + self.soundEffectsNode.volume = 0.25 * volume + } } } -private let queue = DispatchQueue(label: "Audio Dispatch Queue") diff --git a/Sources/BottomMenu/ComposableBottomMenu.swift b/Sources/BottomMenu/ComposableBottomMenu.swift index cb108a78..f3e8f822 100644 --- a/Sources/BottomMenu/ComposableBottomMenu.swift +++ b/Sources/BottomMenu/ComposableBottomMenu.swift @@ -76,7 +76,10 @@ extension View { self.bottomMenu( item: Binding( get: { - viewStore.state?.converted(send: viewStore.send, sendWithAnimation: viewStore.send) + viewStore.state?.converted( + send: { viewStore.send($0) }, + sendWithAnimation: { viewStore.send($0, animation: $1) } + ) }, set: { state, transaction in withAnimation(transaction.disablesAnimations ? nil : transaction.animation) { diff --git a/Sources/Build/Build.swift b/Sources/Build/Build.swift index 6e0177c1..b4158bd9 100644 --- a/Sources/Build/Build.swift +++ b/Sources/Build/Build.swift @@ -36,15 +36,9 @@ public struct Build { import XCTestDynamicOverlay extension Build { - public static let failing = Self( - gitSha: { - XCTFail("\(Self.self).gitSha is unimplemented") - return "" - }, - number: { - XCTFail("\(Self.self).number is unimplemented") - return 0 - } + public static let unimplemented = Self( + gitSha: XCTUnimplemented("\(Self.self).gitSha"), + number: XCTUnimplemented("\(Self.self).number") ) } #endif diff --git a/Sources/ChangelogFeature/ChangelogView.swift b/Sources/ChangelogFeature/ChangelogView.swift index 1e6f9790..bb18e239 100644 --- a/Sources/ChangelogFeature/ChangelogView.swift +++ b/Sources/ChangelogFeature/ChangelogView.swift @@ -30,8 +30,8 @@ public struct ChangelogState: Equatable { public enum ChangelogAction: Equatable { case change(id: Build.Number, action: ChangeAction) - case changelogResponse(Result) - case onAppear + case changelogResponse(TaskResult) + case task case updateButtonTapped } @@ -39,7 +39,6 @@ public struct ChangelogEnvironment { public var apiClient: ApiClient public var applicationClient: UIApplicationClient public var build: Build - public var mainQueue: AnySchedulerOf public var serverConfig: ServerConfigClient public var userDefaults: UserDefaultsClient @@ -47,14 +46,12 @@ public struct ChangelogEnvironment { apiClient: ApiClient, applicationClient: UIApplicationClient, build: Build, - mainQueue: AnySchedulerOf, serverConfig: ServerConfigClient, userDefaults: UserDefaultsClient ) { self.apiClient = apiClient self.applicationClient = applicationClient self.build = build - self.mainQueue = mainQueue self.serverConfig = serverConfig self.userDefaults = userDefaults } @@ -106,23 +103,28 @@ public let changelogReducer = Reducer< state.isRequestInFlight = false return .none - case .onAppear: + case .task: state.currentBuild = environment.build.number() state.isRequestInFlight = true - return environment.apiClient.apiRequest( - route: .changelog(build: environment.build.number()), - as: Changelog.self - ) - .receive(on: environment.mainQueue) - .catchToEffect(ChangelogAction.changelogResponse) + return .task { + await .changelogResponse( + TaskResult { + try await environment.apiClient.apiRequest( + route: .changelog(build: environment.build.number()), + as: Changelog.self + ) + } + ) + } case .updateButtonTapped: - return environment.applicationClient.open( - environment.serverConfig.config().appStoreUrl.absoluteURL, - [:] - ) - .fireAndForget() + return .fireAndForget { + _ = await environment.applicationClient.open( + environment.serverConfig.config().appStoreUrl.absoluteURL, + [:] + ) + } } } ) @@ -184,7 +186,7 @@ public struct ChangelogView: View { } .padding() } - .onAppear { viewStore.send(.onAppear) } + .task { await viewStore.send(.task).finish() } } } } @@ -205,7 +207,7 @@ public struct ChangelogView: View { $0.override( routeCase: /ServerRoute.Api.Route.changelog(build:), withResponse: { _ in - .ok( + try await OK( update(Changelog.current) { $0.changes.append( Changelog.Change( @@ -223,7 +225,6 @@ public struct ChangelogView: View { build: update(.noop) { $0.number = { 98 } }, - mainQueue: .immediate, serverConfig: .noop, userDefaults: update(.noop) { $0.integerForKey = { _ in 98 } diff --git a/Sources/ComposableGameCenter/CrossPlatformSupport.swift b/Sources/ComposableGameCenter/CrossPlatformSupport.swift index 8fc779b1..c999916c 100644 --- a/Sources/ComposableGameCenter/CrossPlatformSupport.swift +++ b/Sources/ComposableGameCenter/CrossPlatformSupport.swift @@ -1,38 +1,14 @@ -#if canImport(AppKit) - import AppKit - - public typealias ViewController = NSViewController - - extension ViewController { - public func present() { - NSApplication.shared.windows - .first? - .beginSheet(NSWindow(contentViewController: self), completionHandler: nil) - } - - public func dismiss() { - guard - let sheet = NSApplication.shared.windows.first(where: { $0.contentViewController == self }) - else { return } - NSApplication.shared.windows - .first? - .endSheet(sheet) - } - } -#endif - -#if canImport(UIKit) +#if os(iOS) import UIKit - public typealias ViewController = UIViewController - @available(iOSApplicationExtension, unavailable) - extension ViewController { + extension UIViewController { public func present() { - UIApplication.shared.windows - .first(where: \.isKeyWindow)? - .rootViewController? - .present(self, animated: true) + guard + let scene = UIKit.UIApplication.shared.connectedScenes.first(where: { $0 is UIWindowScene }) + as? UIWindowScene + else { return } + scene.keyWindow?.rootViewController?.present(self, animated: true) } public func dismiss() { diff --git a/Sources/ComposableGameCenter/Interface.swift b/Sources/ComposableGameCenter/Interface.swift index 9629f058..a3bbdddd 100644 --- a/Sources/ComposableGameCenter/Interface.swift +++ b/Sources/ComposableGameCenter/Interface.swift @@ -6,8 +6,8 @@ import Tagged public struct GameCenterClient { public var gameCenterViewController: GameCenterViewControllerClient public var localPlayer: LocalPlayerClient - public var reportAchievements: ([GKAchievement]) -> Effect - public var showNotificationBanner: (NotificationBannerRequest) -> Effect + public var reportAchievements: @Sendable ([GKAchievement]) async throws -> Void + public var showNotificationBanner: @Sendable (NotificationBannerRequest) async -> Void public var turnBasedMatch: TurnBasedMatchClient public var turnBasedMatchmakerViewController: TurnBasedMatchmakerViewControllerClient @@ -23,19 +23,15 @@ public struct GameCenterClient { } public struct GameCenterViewControllerClient { - public var present: Effect - public var dismiss: Effect - - public enum DelegateEvent: Equatable { - case didFinish - } + public var present: @Sendable () async -> Void + public var dismiss: @Sendable () async -> Void } public struct LocalPlayerClient { - public var authenticate: Effect - public var listener: Effect - public var localPlayer: () -> LocalPlayer - public var presentAuthenticationViewController: Effect + public var authenticate: @Sendable () async throws -> Void + public var listener: @Sendable () -> AsyncStream + public var localPlayer: @Sendable () -> LocalPlayer + public var presentAuthenticationViewController: @Sendable () async -> Void public enum ListenerEvent: Equatable { case challenge(ChallengeEvent) @@ -73,23 +69,18 @@ public struct LocalPlayerClient { } public struct TurnBasedMatchClient { - public var endMatchInTurn: (EndMatchInTurnRequest) -> Effect - public var endTurn: (EndTurnRequest) -> Effect - public var load: (TurnBasedMatch.Id) -> Effect - public var loadMatches: () -> Effect<[TurnBasedMatch], Error> - public var participantQuitInTurn: - (TurnBasedMatch.Id, Data) - -> Effect - public var participantQuitOutOfTurn: - (TurnBasedMatch.Id) - -> Effect - public var rematch: (TurnBasedMatch.Id) -> Effect - public var remove: (TurnBasedMatch) -> Effect - public var saveCurrentTurn: (TurnBasedMatch.Id, Data) -> Effect - public var sendReminder: (SendReminderRequest) -> Effect + public var endMatchInTurn: @Sendable (EndMatchInTurnRequest) async throws -> Void + public var endTurn: @Sendable (EndTurnRequest) async throws -> Void + public var load: @Sendable (TurnBasedMatch.Id) async throws -> TurnBasedMatch + public var loadMatches: @Sendable () async throws -> [TurnBasedMatch] + public var participantQuitInTurn: @Sendable (TurnBasedMatch.Id, Data) async throws -> Void + public var participantQuitOutOfTurn: @Sendable (TurnBasedMatch.Id) async throws -> Void + public var rematch: @Sendable (TurnBasedMatch.Id) async throws -> TurnBasedMatch + public var remove: @Sendable (TurnBasedMatch) async throws -> Void + public var saveCurrentTurn: @Sendable (TurnBasedMatch.Id, Data) async throws -> Void + public var sendReminder: @Sendable (SendReminderRequest) async throws -> Void public struct EndMatchInTurnRequest: Equatable { - // TODO: public var matchOutcomes: [GKTurnBasedMatch.Outcome] or [String: GKTurnBasedMatch.Outcome] public var localPlayerMatchOutcome: GKTurnBasedMatch.Outcome public var localPlayerId: Player.Id public var matchId: TurnBasedMatch.Id @@ -148,15 +139,10 @@ public struct TurnBasedMatchClient { } public struct TurnBasedMatchmakerViewControllerClient { - public var present: (_ showExistingMatches: Bool) -> Effect - public var dismiss: Effect - - public enum DelegateEvent: Equatable { - case wasCancelled - case didFailWithError(NSError) - } + public var present: @Sendable (_ showExistingMatches: Bool) async throws -> Void + public var dismiss: @Sendable () async -> Void - public func present(showExistingMatches: Bool = true) -> Effect { - self.present(showExistingMatches) + public func present(showExistingMatches: Bool = true) async throws { + try await self.present(showExistingMatches) } } diff --git a/Sources/ComposableGameCenter/Live.swift b/Sources/ComposableGameCenter/Live.swift index c1a87a2b..b3eeb6bf 100644 --- a/Sources/ComposableGameCenter/Live.swift +++ b/Sources/ComposableGameCenter/Live.swift @@ -1,428 +1,379 @@ -import Combine -import CombineHelpers -import ComposableArchitecture -import GameKit +#if os(iOS) + import Combine + import CombineHelpers + import ComposableArchitecture + import GameKit -@available(iOSApplicationExtension, unavailable) -extension GameCenterClient { - public static var live: Self { - return Self( - gameCenterViewController: .live, - localPlayer: .live, - reportAchievements: { achievements in - .future { callback in - GKAchievement.report(achievements) { error in - callback(error.map(Result.failure) ?? .success(())) - } - } - }, - showNotificationBanner: { request in - .future { callback in - GKNotificationBanner.show(withTitle: request.title, message: request.message) { - callback(.success(())) - } - } - }, - turnBasedMatch: .live, - turnBasedMatchmakerViewController: .live - ) + @available(iOSApplicationExtension, unavailable) + extension GameCenterClient { + public static var live: Self { + return Self( + gameCenterViewController: .live, + localPlayer: .live, + reportAchievements: { try await GKAchievement.report($0) }, + showNotificationBanner: { + await GKNotificationBanner.show(withTitle: $0.title, message: $0.message) + }, + turnBasedMatch: .live, + turnBasedMatchmakerViewController: .live + ) + } } -} -@available(iOSApplicationExtension, unavailable) -extension GameCenterViewControllerClient { - public static let live = Self( - present: .run { subscriber in - final class Delegate: NSObject, GKGameCenterControllerDelegate { - let subscriber: Effect.Subscriber + @available(iOSApplicationExtension, unavailable) + extension GameCenterViewControllerClient { + public static var live: Self { + actor Presenter { + var viewController: GKGameCenterViewController? + + func present() async { + final class Delegate: NSObject, GKGameCenterControllerDelegate { + let continuation: AsyncStream.Continuation - init(subscriber: Effect.Subscriber) { - self.subscriber = subscriber + init(continuation: AsyncStream.Continuation) { + self.continuation = continuation + } + + func gameCenterViewControllerDidFinish( + _ gameCenterViewController: GKGameCenterViewController + ) { + self.continuation.yield() + self.continuation.finish() + } + } + + await self.dismiss() + let viewController = await GKGameCenterViewController() + self.viewController = viewController + _ = await AsyncStream { continuation in + Task { + await MainActor.run { + let delegate = Delegate(continuation: continuation) + continuation.onTermination = { _ in + _ = delegate + } + viewController.gameCenterDelegate = delegate + viewController.present() + } + } + } + .first(where: { _ in true }) } - func gameCenterViewControllerDidFinish( - _ gameCenterViewController: GKGameCenterViewController - ) { - self.subscriber.send(.didFinish) - self.subscriber.send(completion: .finished) + func dismiss() async { + guard let viewController = self.viewController else { return } + await viewController.dismiss() + self.viewController = nil } } - let viewController = GKGameCenterViewController() - Self.viewController = viewController - var delegate: Optional = Delegate(subscriber: subscriber) - viewController.gameCenterDelegate = delegate - viewController.present() + let presenter = Presenter() - return AnyCancellable { - delegate = nil - viewController.dismiss() - } - }, - dismiss: .fireAndForget { - guard let viewController = Self.viewController else { return } - viewController.dismiss() - Self.viewController = nil + return Self( + present: { await presenter.present() }, + dismiss: { await presenter.dismiss() } + ) } - ) - private static var viewController: GKGameCenterViewController? -} + private static var viewController: GKGameCenterViewController? + } -@available(iOSApplicationExtension, unavailable) -extension LocalPlayerClient { - public static var live: Self { - var localPlayer: GKLocalPlayer { .local } + @available(iOSApplicationExtension, unavailable) + extension LocalPlayerClient { + public static var live: Self { + var localPlayer: GKLocalPlayer { .local } - return Self( - authenticate: - Effect - .run { subscriber in - localPlayer.authenticateHandler = { viewController, error in - subscriber.send(error.map { $0 as NSError }) - if viewController != nil { - Self.viewController = viewController - } - } - return AnyCancellable { - Self.viewController?.dismiss() - Self.viewController = nil - } - } - .shareReplay(1) - .eraseToEffect(), - listener: - Effect - .run { subscriber in - class Listener: NSObject, GKLocalPlayerListener { - let subscriber: Effect.Subscriber - - init(subscriber: Effect.Subscriber) { - self.subscriber = subscriber - } - - func player( - _ player: GKPlayer, didComplete challenge: GKChallenge, - issuedByFriend friendPlayer: GKPlayer - ) { - self.subscriber.send( - .challenge(.didComplete(challenge, issuedByFriend: friendPlayer))) - } - func player(_ player: GKPlayer, didReceive challenge: GKChallenge) { - self.subscriber.send(.challenge(.didReceive(challenge))) - } - func player( - _ player: GKPlayer, issuedChallengeWasCompleted challenge: GKChallenge, - byFriend friendPlayer: GKPlayer - ) { - self.subscriber.send( - .challenge(.issuedChallengeWasCompleted(challenge, byFriend: friendPlayer))) - } - func player(_ player: GKPlayer, wantsToPlay challenge: GKChallenge) { - self.subscriber.send(.challenge(.wantsToPlay(challenge))) - } - func player(_ player: GKPlayer, didAccept invite: GKInvite) { - self.subscriber.send(.invite(.didAccept(invite))) - } - func player( - _ player: GKPlayer, didRequestMatchWithRecipients recipientPlayers: [GKPlayer] - ) { - self.subscriber.send(.invite(.didRequestMatchWithRecipients(recipientPlayers))) - } - func player(_ player: GKPlayer, didModifySavedGame savedGame: GKSavedGame) { - self.subscriber.send(.savedGame(.didModifySavedGame(savedGame))) - } - func player(_ player: GKPlayer, hasConflictingSavedGames savedGames: [GKSavedGame]) { - self.subscriber.send(.savedGame(.hasConflictingSavedGames(savedGames))) - } - func player( - _ player: GKPlayer, didRequestMatchWithOtherPlayers playersToInvite: [GKPlayer] - ) { - self.subscriber.send(.turnBased(.didRequestMatchWithOtherPlayers(playersToInvite))) - } - func player(_ player: GKPlayer, matchEnded match: GKTurnBasedMatch) { - self.subscriber.send(.turnBased(.matchEnded(.init(rawValue: match)))) - } - func player( - _ player: GKPlayer, receivedExchangeCancellation exchange: GKTurnBasedExchange, - for match: GKTurnBasedMatch - ) { - self.subscriber.send( - .turnBased(.receivedExchangeCancellation(exchange, match: .init(rawValue: match)))) - } - func player( - _ player: GKPlayer, receivedExchangeReplies replies: [GKTurnBasedExchangeReply], - forCompletedExchange exchange: GKTurnBasedExchange, for match: GKTurnBasedMatch - ) { - self.subscriber.send( - .turnBased(.receivedExchangeReplies(replies, match: .init(rawValue: match)))) - } - func player( - _ player: GKPlayer, receivedExchangeRequest exchange: GKTurnBasedExchange, - for match: GKTurnBasedMatch - ) { - self.subscriber.send( - .turnBased(.receivedExchangeRequest(exchange, match: .init(rawValue: match)))) - } - func player( - _ player: GKPlayer, receivedTurnEventFor match: GKTurnBasedMatch, - didBecomeActive: Bool - ) { - self.subscriber.send( - .turnBased( - .receivedTurnEventForMatch( - .init(rawValue: match), didBecomeActive: didBecomeActive))) + return Self( + // TODO: Used to use `shareReplay(1)` here. Bring back using some local `ActorIsolated`? + authenticate: { + _ = try await AsyncThrowingStream { continuation in + localPlayer.authenticateHandler = { viewController, error in + if let error = error { + continuation.finish(throwing: error) + return + } + continuation.finish() + if viewController != nil { + Self.viewController = viewController + } } - func player(_ player: GKPlayer, wantsToQuitMatch match: GKTurnBasedMatch) { - self.subscriber.send(.turnBased(.wantsToQuitMatch(.init(rawValue: match)))) + continuation.onTermination = { _ in + Task { + await Self.viewController?.dismiss() + Self.viewController = nil + } } } + .first(where: { true }) + }, + listener: { + AsyncStream { continuation in + class Listener: NSObject, GKLocalPlayerListener { + let continuation: AsyncStream.Continuation - let id = UUID() - let listener = Listener(subscriber: subscriber) - Self.listeners[id] = listener - localPlayer.register(listener) + init(continuation: AsyncStream.Continuation) { + self.continuation = continuation + } - return AnyCancellable { - localPlayer.unregisterListener(Self.listeners[id]!) - Self.listeners[id] = nil - } - } - .eraseToEffect(), - localPlayer: { .init(rawValue: localPlayer) }, - presentAuthenticationViewController: .run { _ in - Self.viewController?.present() - return AnyCancellable { - Self.viewController?.dismiss() - Self.viewController = nil - } - } - ) - } + func player( + _ player: GKPlayer, didComplete challenge: GKChallenge, + issuedByFriend friendPlayer: GKPlayer + ) { + self.continuation.yield( + .challenge(.didComplete(challenge, issuedByFriend: friendPlayer))) + } + func player(_ player: GKPlayer, didReceive challenge: GKChallenge) { + self.continuation.yield(.challenge(.didReceive(challenge))) + } + func player( + _ player: GKPlayer, issuedChallengeWasCompleted challenge: GKChallenge, + byFriend friendPlayer: GKPlayer + ) { + self.continuation.yield( + .challenge(.issuedChallengeWasCompleted(challenge, byFriend: friendPlayer))) + } + func player(_ player: GKPlayer, wantsToPlay challenge: GKChallenge) { + self.continuation.yield(.challenge(.wantsToPlay(challenge))) + } + func player(_ player: GKPlayer, didAccept invite: GKInvite) { + self.continuation.yield(.invite(.didAccept(invite))) + } + func player( + _ player: GKPlayer, didRequestMatchWithRecipients recipientPlayers: [GKPlayer] + ) { + self.continuation.yield(.invite(.didRequestMatchWithRecipients(recipientPlayers))) + } + func player(_ player: GKPlayer, didModifySavedGame savedGame: GKSavedGame) { + self.continuation.yield(.savedGame(.didModifySavedGame(savedGame))) + } + func player(_ player: GKPlayer, hasConflictingSavedGames savedGames: [GKSavedGame]) { + self.continuation.yield(.savedGame(.hasConflictingSavedGames(savedGames))) + } + func player( + _ player: GKPlayer, didRequestMatchWithOtherPlayers playersToInvite: [GKPlayer] + ) { + self.continuation.yield( + .turnBased(.didRequestMatchWithOtherPlayers(playersToInvite))) + } + func player(_ player: GKPlayer, matchEnded match: GKTurnBasedMatch) { + self.continuation.yield(.turnBased(.matchEnded(.init(rawValue: match)))) + } + func player( + _ player: GKPlayer, receivedExchangeCancellation exchange: GKTurnBasedExchange, + for match: GKTurnBasedMatch + ) { + self.continuation.yield( + .turnBased(.receivedExchangeCancellation(exchange, match: .init(rawValue: match))) + ) + } + func player( + _ player: GKPlayer, receivedExchangeReplies replies: [GKTurnBasedExchangeReply], + forCompletedExchange exchange: GKTurnBasedExchange, for match: GKTurnBasedMatch + ) { + self.continuation.yield( + .turnBased(.receivedExchangeReplies(replies, match: .init(rawValue: match)))) + } + func player( + _ player: GKPlayer, receivedExchangeRequest exchange: GKTurnBasedExchange, + for match: GKTurnBasedMatch + ) { + self.continuation.yield( + .turnBased(.receivedExchangeRequest(exchange, match: .init(rawValue: match)))) + } + func player( + _ player: GKPlayer, receivedTurnEventFor match: GKTurnBasedMatch, + didBecomeActive: Bool + ) { + self.continuation.yield( + .turnBased( + .receivedTurnEventForMatch( + .init(rawValue: match), didBecomeActive: didBecomeActive))) + } + func player(_ player: GKPlayer, wantsToQuitMatch match: GKTurnBasedMatch) { + self.continuation.yield(.turnBased(.wantsToQuitMatch(.init(rawValue: match)))) + } + } - private static var listeners: [UUID: GKLocalPlayerListener] = [:] - private static var viewController: ViewController? -} + let id = UUID() + let listener = Listener(continuation: continuation) + Self.listeners[id] = listener + localPlayer.register(listener) -extension TurnBasedMatchClient { - public static let live = Self( - endMatchInTurn: { request in - .future { callback in - GKTurnBasedMatch.load(withID: request.matchId.rawValue) { match, error in - guard let match = match else { - callback(.failure(error ?? invalidStateError)) - return + continuation.onTermination = { _ in + localPlayer.unregisterListener(Self.listeners[id]!) + Self.listeners[id] = nil + } } - match.message = request.message - match.participants.forEach { participant in - if participant.status == .active, let player = participant.player { - let matchOutcome = - request.localPlayerMatchOutcome == .tied - ? .tied - : player.gamePlayerID == request.localPlayerId.rawValue - ? request.localPlayerMatchOutcome - : request.localPlayerMatchOutcome == .won - ? .lost - : .won - participant.matchOutcome = matchOutcome - if match.currentParticipant == participant { - match.currentParticipant?.matchOutcome = matchOutcome + }, + localPlayer: { .init(rawValue: localPlayer) }, + presentAuthenticationViewController: { + await Self.viewController?.present() + await AsyncStream { continuation in + continuation.onTermination = { _ in + Task { + await Self.viewController?.dismiss() + Self.viewController = nil } } } - match.endMatchInTurn(withMatch: request.matchData) { error in - callback(error.map(Result.failure) ?? .success(())) - } - } - } - }, - endTurn: { request in - .future { callback in - GKTurnBasedMatch.load(withID: request.matchId.rawValue) { match, error in - guard let match = match else { - callback(.failure(error ?? invalidStateError)) - return - } - match.message = request.message - match.endTurn( - withNextParticipants: match.participants - .filter { $0.player?.gamePlayerID != match.currentParticipant?.player?.gamePlayerID }, - turnTimeout: GKTurnTimeoutDefault, - match: request.matchData - ) { error in callback(error.map(Result.failure) ?? .success(())) } - } - } - }, - load: { matchId in - .future { callback in - GKTurnBasedMatch.load(withID: matchId.rawValue) { match, error in - callback( - match.map { .success(.init(rawValue: $0)) } - ?? .failure(error ?? invalidStateError) - ) - } - } - }, - loadMatches: { - .future { callback in - GKTurnBasedMatch.loadMatches { matches, error in - callback( - matches.map { .success($0.map(TurnBasedMatch.init(rawValue:))) } - ?? .failure(error ?? invalidStateError) - ) - } - } - }, - participantQuitInTurn: { matchId, matchData in - .future { callback in - GKTurnBasedMatch.load(withID: matchId.rawValue) { match, error in - guard let match = match else { - callback(.success(error)) - return - } - match.participantQuitInTurn( - with: .quit, - nextParticipants: match.participants - .filter { $0.player?.gamePlayerID != match.currentParticipant?.player?.gamePlayerID }, - turnTimeout: 0, - match: matchData - ) { callback(.success($0)) } - } - } - }, - participantQuitOutOfTurn: { matchId in - .future { callback in - GKTurnBasedMatch.load(withID: matchId.rawValue) { match, error in - guard let match = match else { - callback(.success(error)) - return - } - match.participantQuitOutOfTurn(with: .quit) { - callback(.success($0)) - } + .first(where: { true }) } - } - }, - rematch: { matchId in - .future { callback in - GKTurnBasedMatch.load(withID: matchId.rawValue) { match, error in - guard let match = match else { - callback(.failure(error ?? invalidStateError)) - return - } - match.rematch { match, error in - callback( - match.map { .success(.init(rawValue: $0)) } - ?? .failure(error ?? invalidStateError) - ) + ) + } + + private static var listeners: [UUID: GKLocalPlayerListener] = [:] + private static var viewController: UIViewController? + } + + extension TurnBasedMatchClient { + public static let live = Self( + endMatchInTurn: { request in + let match = try await GKTurnBasedMatch.load(withID: request.matchId.rawValue) + match.message = request.message + match.participants.forEach { participant in + if participant.status == .active, let player = participant.player { + let matchOutcome = + request.localPlayerMatchOutcome == .tied + ? .tied + : player.gamePlayerID == request.localPlayerId.rawValue + ? request.localPlayerMatchOutcome + : request.localPlayerMatchOutcome == .won + ? .lost + : .won + participant.matchOutcome = matchOutcome + if match.currentParticipant == participant { + match.currentParticipant?.matchOutcome = matchOutcome + } } } - } - }, - remove: { match in - .future { callback in + try await match.endMatchInTurn(withMatch: request.matchData) + }, + endTurn: { request in + let match = try await GKTurnBasedMatch.load(withID: request.matchId.rawValue) + match.message = request.message + try await match.endTurn( + withNextParticipants: match.participants + .filter { $0.player?.gamePlayerID != match.currentParticipant?.player?.gamePlayerID }, + turnTimeout: GKTurnTimeoutDefault, + match: request.matchData + ) + }, + load: { matchId in + let match = try await GKTurnBasedMatch.load(withID: matchId.rawValue) + return try await TurnBasedMatch(rawValue: GKTurnBasedMatch.load(withID: matchId.rawValue)) + }, + loadMatches: { try await GKTurnBasedMatch.loadMatches().map(TurnBasedMatch.init) }, + participantQuitInTurn: { matchId, matchData in + let match = try await GKTurnBasedMatch.load(withID: matchId.rawValue) + try await match.participantQuitInTurn( + with: .quit, + nextParticipants: match.participants + .filter { $0.player?.gamePlayerID != match.currentParticipant?.player?.gamePlayerID }, + turnTimeout: 0, + match: matchData + ) + }, + participantQuitOutOfTurn: { matchId in + let match = try await GKTurnBasedMatch.load(withID: matchId.rawValue) + try await match.participantQuitOutOfTurn(with: .quit) + }, + rematch: { matchId in + let match = try await GKTurnBasedMatch.load(withID: matchId.rawValue) + return try await TurnBasedMatch(rawValue: match.rematch()) + }, + remove: { match in guard let turnBasedMatch = match.rawValue else { struct RawValueWasNil: Error {} - callback(.failure(RawValueWasNil())) - return - } - turnBasedMatch.remove { error in - callback( - error.map(Result.failure) - ?? .success(()) - ) - } - } - }, - saveCurrentTurn: { matchId, matchData in - .future { callback in - GKTurnBasedMatch.load(withID: matchId.rawValue) { match, error in - guard let match = match else { - callback(.failure(error ?? invalidStateError)) - return - } - match.saveCurrentTurn(withMatch: matchData) { error in - callback(error.map(Result.failure) ?? .success(())) - } - } - } - }, - sendReminder: { request in - .future { callback in - GKTurnBasedMatch.load(withID: request.matchId.rawValue) { match, error in - guard let match = match else { - callback(.failure(error ?? invalidStateError)) - return - } - match.sendReminder( - to: request.participantsAtIndices.map { match.participants[$0] }, - localizableMessageKey: request.key, - arguments: request.arguments - ) { error in callback(error.map(Result.failure) ?? .success(())) } + throw RawValueWasNil() } + try await turnBasedMatch.remove() + }, + saveCurrentTurn: { matchId, matchData in + let match = try await GKTurnBasedMatch.load(withID: matchId.rawValue) + try await match.saveCurrentTurn(withMatch: matchData) + }, + sendReminder: { request in + let match = try await GKTurnBasedMatch.load(withID: request.matchId.rawValue) + try await match.sendReminder( + to: request.participantsAtIndices.map { match.participants[$0] }, + localizableMessageKey: request.key, + arguments: request.arguments + ) } - } - ) -} + ) + } -@available(iOSApplicationExtension, unavailable) -extension TurnBasedMatchmakerViewControllerClient { - public static let live = Self( - present: { showExistingMatches in - .run { subscriber in - class Delegate: NSObject, GKTurnBasedMatchmakerViewControllerDelegate { - let subscriber: Effect.Subscriber + @available(iOSApplicationExtension, unavailable) + extension TurnBasedMatchmakerViewControllerClient { + public static var live: Self { + actor Presenter { + var viewController: GKTurnBasedMatchmakerViewController? - init(subscriber: Effect.Subscriber) { - self.subscriber = subscriber - } + func present(showExistingMatches: Bool) async throws { + final class Delegate: NSObject, GKTurnBasedMatchmakerViewControllerDelegate { + let continuation: AsyncThrowingStream.Continuation - func turnBasedMatchmakerViewControllerWasCancelled( - _ viewController: GKTurnBasedMatchmakerViewController - ) { - self.subscriber.send(.wasCancelled) - self.subscriber.send(completion: .finished) - } + init(continuation: AsyncThrowingStream.Continuation) { + self.continuation = continuation + } - func turnBasedMatchmakerViewController( - _ viewController: GKTurnBasedMatchmakerViewController, didFailWithError error: Error - ) { - self.subscriber.send(.didFailWithError(error as NSError)) - self.subscriber.send(completion: .finished) + func turnBasedMatchmakerViewControllerWasCancelled( + _ viewController: GKTurnBasedMatchmakerViewController + ) { + self.continuation.finish(throwing: CancellationError()) + } + + func turnBasedMatchmakerViewController( + _ viewController: GKTurnBasedMatchmakerViewController, didFailWithError error: Error + ) { + self.continuation.finish(throwing: error) + } } - } - let matchRequest = GKMatchRequest() - matchRequest.inviteMessage = "Let’s play isowords!" // TODO: Pass in/localize - matchRequest.maxPlayers = 2 - matchRequest.minPlayers = 2 - matchRequest.recipientResponseHandler = { player, response in + await self.dismiss() - } + let matchRequest = GKMatchRequest() + matchRequest.inviteMessage = "Let's play isowords!" + matchRequest.maxPlayers = 2 + matchRequest.minPlayers = 2 - let viewController = GKTurnBasedMatchmakerViewController(matchRequest: matchRequest) - viewController.showExistingMatches = showExistingMatches - Self.viewController = viewController - var delegate: Optional = Delegate(subscriber: subscriber) - viewController.turnBasedMatchmakerDelegate = delegate - viewController.present() + let viewController: GKTurnBasedMatchmakerViewController = await MainActor.run { + let viewController = GKTurnBasedMatchmakerViewController(matchRequest: matchRequest) + viewController.showExistingMatches = showExistingMatches + return viewController + } + self.viewController = viewController - return AnyCancellable { - delegate = nil - viewController.dismiss() - Self.viewController = nil + _ = try await AsyncThrowingStream { continuation in + Task { + await MainActor.run { + let delegate = Delegate(continuation: continuation) + continuation.onTermination = { _ in + _ = delegate + Task { await self.dismiss() } + } + viewController.turnBasedMatchmakerDelegate = delegate + viewController.present() + } + } + } + .first(where: { _ in true }) + } + + func dismiss() async { + guard let viewController = self.viewController else { return } + await viewController.dismiss() + self.viewController = nil } } - }, - dismiss: .fireAndForget { - guard let viewController = Self.viewController else { return } - viewController.dismiss() - Self.viewController = nil - } - ) - private static var viewController: GKTurnBasedMatchmakerViewController? -} + let presenter = Presenter() -private let invalidStateError = NSError(domain: "co.pointfree", code: -1) + return Self( + present: { try await presenter.present(showExistingMatches: $0) }, + dismiss: { await presenter.dismiss() } + ) + } + } +#endif diff --git a/Sources/ComposableGameCenter/Mocks.swift b/Sources/ComposableGameCenter/Mocks.swift index 52f4a28a..135f8271 100644 --- a/Sources/ComposableGameCenter/Mocks.swift +++ b/Sources/ComposableGameCenter/Mocks.swift @@ -2,8 +2,8 @@ extension GameCenterClient { public static let noop = Self( gameCenterViewController: .noop, localPlayer: .noop, - reportAchievements: { _ in .none }, - showNotificationBanner: { _ in .none }, + reportAchievements: { _ in }, + showNotificationBanner: { _ in }, turnBasedMatch: .noop, turnBasedMatchmakerViewController: .noop ) @@ -11,15 +11,15 @@ extension GameCenterClient { extension GameCenterViewControllerClient { public static let noop = Self( - present: .none, - dismiss: .none + present: {}, + dismiss: {} ) } extension LocalPlayerClient { public static let noop = Self( - authenticate: .none, - listener: .none, + authenticate: {}, + listener: { .finished }, localPlayer: { LocalPlayer( isAuthenticated: false, @@ -29,29 +29,29 @@ extension LocalPlayerClient { ) ) }, - presentAuthenticationViewController: .none + presentAuthenticationViewController: {} ) } extension TurnBasedMatchClient { public static let noop = Self( - endMatchInTurn: { _ in .none }, - endTurn: { _ in .none }, - load: { _ in .none }, - loadMatches: { .none }, - participantQuitInTurn: { _, _ in .none }, - participantQuitOutOfTurn: { _ in .none }, - rematch: { _ in .none }, - remove: { _ in .none }, - saveCurrentTurn: { _, _ in .none }, - sendReminder: { _ in .none } + endMatchInTurn: { _ in }, + endTurn: { _ in }, + load: { _ in try await Task.never() }, + loadMatches: { [] }, + participantQuitInTurn: { _, _ in }, + participantQuitOutOfTurn: { _ in }, + rematch: { _ in try await Task.never() }, + remove: { _ in }, + saveCurrentTurn: { _, _ in }, + sendReminder: { _ in } ) } extension TurnBasedMatchmakerViewControllerClient { public static let noop = Self( - present: { _ in .none }, - dismiss: .none + present: { _ in }, + dismiss: {} ) } @@ -59,61 +59,53 @@ extension TurnBasedMatchmakerViewControllerClient { import XCTestDynamicOverlay extension GameCenterClient { - public static let failing = Self( - gameCenterViewController: .failing, - localPlayer: .failing, - reportAchievements: { _ in .failing("\(Self.self).reportAchievements is unimplemented") }, - showNotificationBanner: { _ in - .failing("\(Self.self).showNotificationBanner is unimplemented") - }, - turnBasedMatch: .failing, - turnBasedMatchmakerViewController: .failing + public static let unimplemented = Self( + gameCenterViewController: .unimplemented, + localPlayer: .unimplemented, + reportAchievements: XCTUnimplemented("\(Self.self).reportAchievements"), + showNotificationBanner: XCTUnimplemented("\(Self.self).showNotificationBanner"), + turnBasedMatch: .unimplemented, + turnBasedMatchmakerViewController: .unimplemented ) } extension GameCenterViewControllerClient { - public static let failing = Self( - present: .failing("\(Self.self).present is unimplemented"), - dismiss: .failing("\(Self.self).dismiss is unimplemented") + public static let unimplemented = Self( + present: XCTUnimplemented("\(Self.self).present"), + dismiss: XCTUnimplemented("\(Self.self).dismiss") ) } extension LocalPlayerClient { - public static let failing = Self( - authenticate: .failing("\(Self.self).authenticate is unimplemented"), - listener: .failing("\(Self.self).listener is unimplemented"), - localPlayer: { - XCTFail("\(Self.self).localPlayer is unimplemented") - return .notAuthenticated - }, - presentAuthenticationViewController: - .failing("\(Self.self).presentAuthenticationViewController is unimplemented") + public static let unimplemented = Self( + authenticate: XCTUnimplemented("\(Self.self).authenticate"), + listener: XCTUnimplemented("\(Self.self).listener", placeholder: .finished), + localPlayer: XCTUnimplemented("\(Self.self).localPlayer", placeholder: .notAuthenticated), + presentAuthenticationViewController: XCTUnimplemented( + "\(Self.self).presentAuthenticationViewController" + ) ) } extension TurnBasedMatchClient { - public static let failing = Self( - endMatchInTurn: { _ in .failing("\(Self.self).endMatchInTurn is unimplemented") }, - endTurn: { _ in .failing("\(Self.self).endTurn is unimplemented") }, - load: { _ in .failing("\(Self.self).load is unimplemented") }, - loadMatches: { .failing("\(Self.self).loadMatches is unimplemented") }, - participantQuitInTurn: { _, _ in - .failing("\(Self.self).participantQuitInTurn is unimplemented") - }, - participantQuitOutOfTurn: { _ in - .failing("\(Self.self).participantQuitOutOfTurn is unimplemented") - }, - rematch: { _ in .failing("\(Self.self).rematch is unimplemented") }, - remove: { _ in .failing("\(Self.self).remove is unimplemented") }, - saveCurrentTurn: { _, _ in .failing("\(Self.self).saveCurrentTurn is unimplemented") }, - sendReminder: { _ in .failing("\(Self.self).sendReminder is unimplemented") } + public static let unimplemented = Self( + endMatchInTurn: XCTUnimplemented("\(Self.self).endMatchInTurn"), + endTurn: XCTUnimplemented("\(Self.self).endTurn"), + load: XCTUnimplemented("\(Self.self).load"), + loadMatches: XCTUnimplemented("\(Self.self).loadMatches"), + participantQuitInTurn: XCTUnimplemented("\(Self.self).participantQuitInTurn"), + participantQuitOutOfTurn: XCTUnimplemented("\(Self.self).participantQuitOutOfTurn"), + rematch: XCTUnimplemented("\(Self.self).rematch"), + remove: XCTUnimplemented("\(Self.self).remove"), + saveCurrentTurn: XCTUnimplemented("\(Self.self).saveCurrentTurn"), + sendReminder: XCTUnimplemented("\(Self.self).sendReminder") ) } extension TurnBasedMatchmakerViewControllerClient { - public static let failing = Self( - present: { _ in .failing("\(Self.self).present is unimplemented") }, - dismiss: .failing("\(Self.self).dismiss is unimplemented") + public static let unimplemented = Self( + present: XCTUnimplemented("\(Self.self).present"), + dismiss: XCTUnimplemented("\(Self.self).dismiss") ) } #endif diff --git a/Sources/ComposableGameCenterHelpers/ComposableGameCenterHelpers.swift b/Sources/ComposableGameCenterHelpers/ComposableGameCenterHelpers.swift deleted file mode 100644 index 52799999..00000000 --- a/Sources/ComposableGameCenterHelpers/ComposableGameCenterHelpers.swift +++ /dev/null @@ -1,29 +0,0 @@ -import ComposableArchitecture -import ComposableGameCenter - -public func forceQuitMatch( - match: TurnBasedMatch, - gameCenter: GameCenterClient -) -> Effect { - let localPlayer = gameCenter.localPlayer.localPlayer() - let currentParticipantIsLocalPlayer = - match.currentParticipant?.player?.gamePlayerId == localPlayer.gamePlayerId - - if currentParticipantIsLocalPlayer { - return gameCenter.turnBasedMatch - .endMatchInTurn( - .init( - for: match.matchId, - matchData: match.matchData ?? Data(), - localPlayerId: localPlayer.gamePlayerId, - localPlayerMatchOutcome: .quit, - message: "\(localPlayer.displayName) forfeited the match." - ) - ) - .fireAndForget() - } else { - return gameCenter.turnBasedMatch - .participantQuitOutOfTurn(match.matchId) - .fireAndForget() - } -} diff --git a/Sources/ComposableStoreKit/Client.swift b/Sources/ComposableStoreKit/Client.swift index 5386637e..5f10b86b 100644 --- a/Sources/ComposableStoreKit/Client.swift +++ b/Sources/ComposableStoreKit/Client.swift @@ -3,14 +3,14 @@ import ComposableArchitecture import StoreKit public struct StoreKitClient { - public var addPayment: (SKPayment) -> Effect - public var appStoreReceiptURL: () -> URL? - public var isAuthorizedForPayments: () -> Bool - public var fetchProducts: (Set) -> Effect - public var finishTransaction: (PaymentTransaction) -> Effect - public var observer: Effect - public var requestReview: () -> Effect - public var restoreCompletedTransactions: () -> Effect + public var addPayment: @Sendable (SKPayment) async -> Void + public var appStoreReceiptURL: @Sendable () -> URL? + public var isAuthorizedForPayments: @Sendable () -> Bool + public var fetchProducts: @Sendable (Set) async throws -> ProductsResponse + public var finishTransaction: @Sendable (PaymentTransaction) async -> Void + public var observer: @Sendable () -> AsyncStream + public var requestReview: @Sendable () async -> Void + public var restoreCompletedTransactions: @Sendable () async -> Void public enum PaymentTransactionObserverEvent: Equatable { case removedTransactions([PaymentTransaction]) diff --git a/Sources/ComposableStoreKit/Live.swift b/Sources/ComposableStoreKit/Live.swift index 36dc5a74..562926b5 100644 --- a/Sources/ComposableStoreKit/Live.swift +++ b/Sources/ComposableStoreKit/Live.swift @@ -6,97 +6,84 @@ import StoreKit extension StoreKitClient { public static func live() -> Self { return Self( - addPayment: { payment in - .fireAndForget { - SKPaymentQueue.default().add(payment) - } - }, + addPayment: { SKPaymentQueue.default().add($0) }, appStoreReceiptURL: { Bundle.main.appStoreReceiptURL }, - isAuthorizedForPayments: SKPaymentQueue.canMakePayments, + isAuthorizedForPayments: { SKPaymentQueue.canMakePayments() }, fetchProducts: { products in - .run { subscriber in + let stream = AsyncThrowingStream { continuation in let request = SKProductsRequest(productIdentifiers: products) - var delegate: ProductRequest? = ProductRequest(subscriber: subscriber) + let delegate = ProductRequest(continuation: continuation) request.delegate = delegate request.start() - - return AnyCancellable { - request.cancel() - request.delegate = nil - delegate = nil + continuation.onTermination = { [request = UncheckedSendable(request)] _ in + request.value.cancel() + _ = delegate } } + guard let response = try await stream.first(where: { _ in true }) + else { throw CancellationError() } + return response }, finishTransaction: { transaction in - .fireAndForget { - guard let skTransaction = transaction.rawValue else { - assertionFailure("The rawValue of this transaction should not be nil: \(transaction)") - return - } - SKPaymentQueue.default().finishTransaction(skTransaction) + guard let skTransaction = transaction.rawValue else { + assertionFailure("The rawValue of this transaction should not be nil: \(transaction)") + return } + SKPaymentQueue.default().finishTransaction(skTransaction) }, - observer: Effect.run { subscriber in - let observer = Observer(subscriber: subscriber) - SKPaymentQueue.default().add(observer) - return AnyCancellable { - SKPaymentQueue.default().remove(observer) + observer: { + AsyncStream { continuation in + let observer = Observer(continuation: continuation) + SKPaymentQueue.default().add(observer) + continuation.onTermination = { _ in SKPaymentQueue.default().remove(observer) } } - } - .share() - .eraseToEffect(), + }, requestReview: { - .fireAndForget { - #if canImport(UIKit) - guard let windowScene = UIApplication.shared.windows.first?.windowScene - else { return } - - SKStoreReviewController.requestReview(in: windowScene) - #endif - } + guard + let scene = await UIApplication.shared.connectedScenes + .first(where: { $0 is UIWindowScene }) + as? UIWindowScene + else { return } + await SKStoreReviewController.requestReview(in: scene) }, - restoreCompletedTransactions: { - .fireAndForget { - SKPaymentQueue.default().restoreCompletedTransactions() - } - } + restoreCompletedTransactions: { SKPaymentQueue.default().restoreCompletedTransactions() } ) } } private class ProductRequest: NSObject, SKProductsRequestDelegate { - let subscriber: Effect.Subscriber + let continuation: AsyncThrowingStream.Continuation - init(subscriber: Effect.Subscriber) { - self.subscriber = subscriber + init(continuation: AsyncThrowingStream.Continuation) { + self.continuation = continuation } func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { - self.subscriber.send( + self.continuation.yield( .init( invalidProductIdentifiers: response.invalidProductIdentifiers, products: response.products.map(StoreKitClient.Product.init(rawValue:)) ) ) - self.subscriber.send(completion: .finished) + self.continuation.finish() } func request(_ request: SKRequest, didFailWithError error: Error) { - self.subscriber.send(completion: .failure(error)) + self.continuation.finish(throwing: error) } } private class Observer: NSObject, SKPaymentTransactionObserver { - let subscriber: Effect.Subscriber + let continuation: AsyncStream.Continuation - init(subscriber: Effect.Subscriber) { - self.subscriber = subscriber + init(continuation: AsyncStream.Continuation) { + self.continuation = continuation } func paymentQueue( _ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction] ) { - self.subscriber.send( + self.continuation.yield( .updatedTransactions( transactions.map(StoreKitClient.PaymentTransaction.init(rawValue:)) ) @@ -106,7 +93,7 @@ private class Observer: NSObject, SKPaymentTransactionObserver { func paymentQueue( _ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction] ) { - self.subscriber.send( + self.continuation.yield( .removedTransactions( transactions.map(StoreKitClient.PaymentTransaction.init(rawValue:)) ) @@ -114,7 +101,7 @@ private class Observer: NSObject, SKPaymentTransactionObserver { } func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) { - self.subscriber.send( + self.continuation.yield( .restoreCompletedTransactionsFinished( transactions: queue.transactions.map(StoreKitClient.PaymentTransaction.init) ) @@ -124,6 +111,7 @@ private class Observer: NSObject, SKPaymentTransactionObserver { func paymentQueue( _ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error ) { - self.subscriber.send(.restoreCompletedTransactionsFailed(error as NSError)) + // TODO: Should this use TaskResult instead? TaskFailure? + self.continuation.yield(.restoreCompletedTransactionsFailed(error as NSError)) } } diff --git a/Sources/ComposableStoreKit/Mocks.swift b/Sources/ComposableStoreKit/Mocks.swift index f206265b..ef001706 100644 --- a/Sources/ComposableStoreKit/Mocks.swift +++ b/Sources/ComposableStoreKit/Mocks.swift @@ -2,14 +2,14 @@ import ComposableArchitecture extension StoreKitClient { public static let noop = Self( - addPayment: { _ in .none }, + addPayment: { _ in }, appStoreReceiptURL: { nil }, isAuthorizedForPayments: { false }, - fetchProducts: { _ in .none }, - finishTransaction: { _ in .none }, - observer: .none, - requestReview: { .none }, - restoreCompletedTransactions: { .none } + fetchProducts: { _ in try await Task.never() }, + finishTransaction: { _ in }, + observer: { AsyncStream { _ in } }, + requestReview: {}, + restoreCompletedTransactions: {} ) } @@ -17,21 +17,19 @@ extension StoreKitClient { import XCTestDynamicOverlay extension StoreKitClient { - public static let failing = Self( - addPayment: { _ in .failing("\(Self.self).addPayment is unimplemented") }, - appStoreReceiptURL: { - XCTFail("\(Self.self).appStoreReceiptURL is unimplemented") - return nil - }, - isAuthorizedForPayments: { - XCTFail("\(Self.self).isAuthorizedForPayments is unimplemented") - return false - }, - fetchProducts: { _ in .failing("\(Self.self).fetchProducts is unimplemented") }, - finishTransaction: { _ in .failing("\(Self.self).finishTransaction is unimplemented") }, - observer: .failing("\(Self.self).observer is unimplemented"), - requestReview: { .failing("\(Self.self).requestReview is unimplemented") }, - restoreCompletedTransactions: { .failing("\(Self.self).fireAndForget is unimplemented") } + public static let unimplemented = Self( + addPayment: XCTUnimplemented("\(Self.self).addPayment"), + appStoreReceiptURL: XCTUnimplemented("\(Self.self).appStoreReceiptURL", placeholder: nil), + isAuthorizedForPayments: XCTUnimplemented( + "\(Self.self).isAuthorizedForPayments", placeholder: false + ), + fetchProducts: XCTUnimplemented("\(Self.self).fetchProducts"), + finishTransaction: XCTUnimplemented("\(Self.self).finishTransaction"), + observer: XCTUnimplemented("\(Self.self).observer", placeholder: .finished), + requestReview: XCTUnimplemented("\(Self.self).requestReview"), + restoreCompletedTransactions: XCTUnimplemented( + "\(Self.self).restoreCompletedTransactions" + ) ) } #endif diff --git a/Sources/ComposableUserNotifications/Interface.swift b/Sources/ComposableUserNotifications/Interface.swift index 1ccb5b33..f923e3bb 100644 --- a/Sources/ComposableUserNotifications/Interface.swift +++ b/Sources/ComposableUserNotifications/Interface.swift @@ -3,18 +3,20 @@ import ComposableArchitecture import UserNotifications public struct UserNotificationClient { - public var add: (UNNotificationRequest) -> Effect - public var delegate: Effect - public var getNotificationSettings: Effect - public var removeDeliveredNotificationsWithIdentifiers: ([String]) -> Effect - public var removePendingNotificationRequestsWithIdentifiers: ([String]) -> Effect - public var requestAuthorization: (UNAuthorizationOptions) -> Effect + public var add: @Sendable (UNNotificationRequest) async throws -> Void + public var delegate: @Sendable () -> AsyncStream + public var getNotificationSettings: @Sendable () async -> Notification.Settings + public var removeDeliveredNotificationsWithIdentifiers: @Sendable ([String]) async -> Void + public var removePendingNotificationRequestsWithIdentifiers: + @Sendable ([String]) async -> Void + public var requestAuthorization: @Sendable (UNAuthorizationOptions) async throws -> Bool public enum DelegateEvent: Equatable { - case didReceiveResponse(Notification.Response, completionHandler: () -> Void) + case didReceiveResponse(Notification.Response, completionHandler: @Sendable () -> Void) case openSettingsForNotification(Notification?) case willPresentNotification( - Notification, completionHandler: (UNNotificationPresentationOptions) -> Void) + Notification, completionHandler: @Sendable (UNNotificationPresentationOptions) -> Void + ) public static func == (lhs: Self, rhs: Self) -> Bool { switch (lhs, rhs) { diff --git a/Sources/ComposableUserNotifications/Live.swift b/Sources/ComposableUserNotifications/Live.swift index 73c38179..3077a5b7 100644 --- a/Sources/ComposableUserNotifications/Live.swift +++ b/Sources/ComposableUserNotifications/Live.swift @@ -4,56 +4,27 @@ import UserNotifications extension UserNotificationClient { public static let live = Self( - add: { request in - .future { callback in - UNUserNotificationCenter.current().add(request) { error in - if let error = error { - callback(.failure(error)) - } else { - callback(.success(())) - } - } - } - }, - delegate: - Effect - .run { subscriber in - var delegate: Optional = Delegate(subscriber: subscriber) + add: { try await UNUserNotificationCenter.current().add($0) }, + delegate: { + AsyncStream { continuation in + let delegate = Delegate(continuation: continuation) UNUserNotificationCenter.current().delegate = delegate - return AnyCancellable { - delegate = nil - } - } - .share() - .eraseToEffect(), - getNotificationSettings: .future { callback in - UNUserNotificationCenter.current().getNotificationSettings { settings in - callback(.success(.init(rawValue: settings))) + continuation.onTermination = { [delegate] _ in } } }, - removeDeliveredNotificationsWithIdentifiers: { identifiers in - .fireAndForget { - UNUserNotificationCenter.current() - .removeDeliveredNotifications(withIdentifiers: identifiers) - } + getNotificationSettings: { + await Notification.Settings( + rawValue: UNUserNotificationCenter.current().notificationSettings() + ) }, - removePendingNotificationRequestsWithIdentifiers: { identifiers in - .fireAndForget { - UNUserNotificationCenter.current() - .removePendingNotificationRequests(withIdentifiers: identifiers) - } + removeDeliveredNotificationsWithIdentifiers: { + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: $0) }, - requestAuthorization: { options in - .future { callback in - UNUserNotificationCenter.current() - .requestAuthorization(options: options) { granted, error in - if let error = error { - callback(.failure(error)) - } else { - callback(.success(granted)) - } - } - } + removePendingNotificationRequestsWithIdentifiers: { + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: $0) + }, + requestAuthorization: { + try await UNUserNotificationCenter.current().requestAuthorization(options: $0) } ) } @@ -79,10 +50,10 @@ extension UserNotificationClient.Notification.Settings { extension UserNotificationClient { fileprivate class Delegate: NSObject, UNUserNotificationCenterDelegate { - let subscriber: Effect.Subscriber + let continuation: AsyncStream.Continuation - init(subscriber: Effect.Subscriber) { - self.subscriber = subscriber + init(continuation: AsyncStream.Continuation) { + self.continuation = continuation } func userNotificationCenter( @@ -90,8 +61,8 @@ extension UserNotificationClient { didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { - self.subscriber.send( - .didReceiveResponse(.init(rawValue: response), completionHandler: completionHandler) + self.continuation.yield( + .didReceiveResponse(.init(rawValue: response)) { completionHandler() } ) } @@ -99,7 +70,7 @@ extension UserNotificationClient { _ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification? ) { - self.subscriber.send( + self.continuation.yield( .openSettingsForNotification(notification.map(Notification.init(rawValue:))) ) } @@ -110,11 +81,8 @@ extension UserNotificationClient { withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { - self.subscriber.send( - .willPresentNotification( - .init(rawValue: notification), - completionHandler: completionHandler - ) + self.continuation.yield( + .willPresentNotification(.init(rawValue: notification)) { completionHandler($0) } ) } } diff --git a/Sources/ComposableUserNotifications/Mocks.swift b/Sources/ComposableUserNotifications/Mocks.swift index 480fb4e5..725f30e7 100644 --- a/Sources/ComposableUserNotifications/Mocks.swift +++ b/Sources/ComposableUserNotifications/Mocks.swift @@ -1,11 +1,11 @@ extension UserNotificationClient { public static let noop = Self( - add: { _ in .none }, - delegate: .none, - getNotificationSettings: .none, - removeDeliveredNotificationsWithIdentifiers: { _ in .none }, - removePendingNotificationRequestsWithIdentifiers: { _ in .none }, - requestAuthorization: { _ in .none } + add: { _ in }, + delegate: { AsyncStream { _ in } }, + getNotificationSettings: { Notification.Settings(authorizationStatus: .notDetermined) }, + removeDeliveredNotificationsWithIdentifiers: { _ in }, + removePendingNotificationRequestsWithIdentifiers: { _ in }, + requestAuthorization: { _ in false } ) } @@ -13,18 +13,18 @@ extension UserNotificationClient { import XCTestDynamicOverlay extension UserNotificationClient { - public static let failing = Self( - add: { _ in .failing("\(Self.self).add is not implemented") }, - delegate: .failing("\(Self.self).delegate is not implemented"), - getNotificationSettings: .failing("\(Self.self).getNotificationSettings is not implemented"), - removeDeliveredNotificationsWithIdentifiers: { _ in - .failing("\(Self.self).removeDeliveredNotificationsWithIdentifiers is not implemented") - }, - removePendingNotificationRequestsWithIdentifiers: { _ in - .failing("\(Self.self).removePendingNotificationRequestsWithIdentifiers is not implemented") - }, - requestAuthorization: { _ in .failing("\(Self.self).requestAuthorization is not implemented") - } + public static let unimplemented = Self( + add: XCTUnimplemented("\(Self.self).add"), + delegate: XCTUnimplemented("\(Self.self).delegate", placeholder: .finished), + getNotificationSettings: XCTUnimplemented( + "\(Self.self).getNotificationSettings", + placeholder: Notification.Settings(authorizationStatus: .notDetermined) + ), + removeDeliveredNotificationsWithIdentifiers: XCTUnimplemented( + "\(Self.self).removeDeliveredNotificationsWithIdentifiers"), + removePendingNotificationRequestsWithIdentifiers: XCTUnimplemented( + "\(Self.self).removePendingNotificationRequestsWithIdentifiers"), + requestAuthorization: XCTUnimplemented("\(Self.self).requestAuthorization") ) } #endif diff --git a/Sources/CubePreview/CubePreviewView.swift b/Sources/CubePreview/CubePreviewView.swift index 304abd52..cb897717 100644 --- a/Sources/CubePreview/CubePreviewView.swift +++ b/Sources/CubePreview/CubePreviewView.swift @@ -59,8 +59,8 @@ public enum CubePreviewAction: BindableAction, Equatable { case binding(BindingAction) case cubeScene(CubeSceneView.ViewAction) case lowPowerModeResponse(Bool) - case onAppear case tap + case task } public struct CubePreviewEnvironment { @@ -87,7 +87,8 @@ public let cubePreviewReducer = Reducer< CubePreviewAction, CubePreviewEnvironment > { state, action, environment in - struct SelectionId: Hashable {} + + enum SelectionID {} switch action { case .binding: @@ -100,79 +101,70 @@ public let cubePreviewReducer = Reducer< state.isOnLowPowerMode = isOn return .none - case .onAppear: - var effects: [Effect] = [ - environment.lowPowerMode.start - .prefix(1) - .map(CubePreviewAction.lowPowerModeResponse) - .eraseToEffect(), - - Effect.none - .delay(for: 1, scheduler: environment.mainQueue) - .eraseToEffect(), - ] - - var accumulatedSelectedFaces: [IndexedCubeFace] = [] - let move = state.moves[state.moveIndex] - switch move.type { + case .tap: + state.nub.location = .offScreenRight + switch state.moves[state.moveIndex].type { case let .playedWord(faces): - for (faceIndex, face) in faces.enumerated() { - accumulatedSelectedFaces.append(face) - let moveDuration = Double.random(in: (0.6...0.8)) - - effects.append( - Effect(value: .set(\.$nub.location, .face(face))) - .receive( - on: environment.mainQueue - .animate(withDuration: moveDuration, options: .curveEaseInOut) - ) - .eraseToEffect() + state.selectedCubeFaces = faces + case .removedCube: + break + } + return .cancel(id: SelectionID.self) + + case .task: + return .run { [move = state.moves[state.moveIndex]] send in + await send( + .lowPowerModeResponse( + await environment.lowPowerMode.start().first(where: { _ in true }) ?? false ) + ) - effects.append( - Effect.merge( - // Press the nub on the first character - faceIndex == 0 ? Effect(value: .set(\.$nub.isPressed, true)) : .none, + try await environment.mainQueue.sleep(for: .seconds(1)) - // Select the faces that have been tapped so far - Effect(value: .set(\.$selectedCubeFaces, accumulatedSelectedFaces)) + var accumulatedSelectedFaces: [IndexedCubeFace] = [] + switch move.type { + case let .playedWord(faces): + for (faceIndex, face) in faces.enumerated() { + accumulatedSelectedFaces.append(face) + let moveDuration = Double.random(in: (0.6...0.8)) + + // Move the nub to the face + await send( + .set(\.$nub.location, .face(face)), + animateWithDuration: moveDuration, + delay: 0, options: .curveEaseInOut ) - .delay( - for: .seconds( - faceIndex == 0 - ? moveDuration - : 0.5 * moveDuration - ), - scheduler: environment.mainQueue.animation() + + // Pause a bit to allow the nub to animate to the face + try await environment.mainQueue.sleep( + for: .seconds(faceIndex == 0 ? moveDuration : 0.5 * moveDuration) ) - .eraseToEffect() - ) - } - effects.append( - Effect(value: .set(\.$nub.isPressed, false)) - ) - effects.append( - Effect(value: .set(\.$nub.location, .offScreenRight)) - .receive(on: environment.mainQueue.animate(withDuration: 1)) - .eraseToEffect() - ) - case let .removedCube(index): - break - } + // Press the nub on the first character + if faceIndex == 0 { + await send(.set(\.$nub.isPressed, true), animation: .default) + } - return Effect.concatenate(effects) - .cancellable(id: SelectionId()) + // Select the faces that have been tapped so far + await send(.set(\.$selectedCubeFaces, accumulatedSelectedFaces), animation: .default) + } - case .tap: - state.nub.location = .offScreenRight - switch state.moves[state.moveIndex].type { - case let .playedWord(faces): - state.selectedCubeFaces = faces - case .removedCube: - break + // Un-press the nub once finished selecting all faces + await send(.set(\.$nub.isPressed, false)) + + // Move the nub off the screen + await send( + .set(\.$nub.location, .offScreenRight), + animateWithDuration: 1, + delay: 0, + options: .curveEaseInOut + ) + + case let .removedCube(index): + break + } } - return .cancel(id: SelectionId()) + .cancellable(id: SelectionID.self) } } .binding() @@ -190,7 +182,6 @@ public let cubePreviewReducer = Reducer< puzzle: \.cubes, selectedWord: \.selectedCubeFaces ) -// TODO: cancel effects on dismiss public struct CubePreviewView: View { @Environment(\.deviceState) var deviceState @@ -253,9 +244,7 @@ public struct CubePreviewView: View { action: CubePreviewAction.cubeScene ) ) - .onAppear { - self.viewStore.send(.onAppear) - } + .task { await self.viewStore.send(.task).finish() } } .background( self.viewStore.isAnimationReduced diff --git a/Sources/DailyChallengeFeature/DailyChallengeResults.swift b/Sources/DailyChallengeFeature/DailyChallengeResults.swift index cd1af4bc..d9eaec8a 100644 --- a/Sources/DailyChallengeFeature/DailyChallengeResults.swift +++ b/Sources/DailyChallengeFeature/DailyChallengeResults.swift @@ -23,27 +23,23 @@ public struct DailyChallengeResultsState: Equatable { public enum DailyChallengeResultsAction: Equatable { case leaderboardResults(LeaderboardResultsAction) case loadHistory - case fetchHistoryResponse(Result) + case fetchHistoryResponse(TaskResult) } public struct DailyChallengeResultsEnvironment { public var apiClient: ApiClient - public var mainQueue: AnySchedulerOf public init( - apiClient: ApiClient, - mainQueue: AnySchedulerOf + apiClient: ApiClient ) { self.apiClient = apiClient - self.mainQueue = mainQueue } } #if DEBUG extension DailyChallengeResultsEnvironment { - public static let failing = Self( - apiClient: .failing, - mainQueue: .failing("mainQueue") + public static let unimplemented = Self( + apiClient: .unimplemented ) } #endif @@ -56,12 +52,7 @@ public let dailyChallengeResultsReducer = Reducer< .pullback( state: \DailyChallengeResultsState.leaderboardResults, action: /DailyChallengeResultsAction.leaderboardResults, - environment: { - .init( - loadResults: $0.apiClient.loadDailyChallengeResults(gameMode:timeScope:), - mainQueue: $0.mainQueue - ) - } + environment: { .init(loadResults: $0.apiClient.loadDailyChallengeResults) } ), .init { state, action, environment in @@ -86,7 +77,7 @@ public let dailyChallengeResultsReducer = Reducer< guard state.leaderboardResults.isTimeScopeMenuVisible else { return .none } - return .init(value: .loadHistory) + return .task { .loadHistory } case .leaderboardResults: return .none @@ -96,21 +87,18 @@ public let dailyChallengeResultsReducer = Reducer< state.history = nil } - struct CancelId: Hashable {} - return environment.apiClient.apiRequest( - route: .dailyChallenge( - .results( - .history( - gameMode: state.leaderboardResults.gameMode, - language: .en + enum CancelID {} + return .task { [gameMode = state.leaderboardResults.gameMode] in + await .fetchHistoryResponse( + TaskResult { + try await environment.apiClient.apiRequest( + route: .dailyChallenge(.results(.history(gameMode: gameMode, language: .en))), + as: DailyChallengeHistoryResponse.self ) - ) - ), - as: DailyChallengeHistoryResponse.self - ) - .receive(on: environment.mainQueue) - .catchToEffect(DailyChallengeResultsAction.fetchHistoryResponse) - .cancellable(id: CancelId(), cancelInFlight: true) + } + ) + } + .cancellable(id: CancelID.self, cancelInFlight: true) } } ) @@ -175,23 +163,25 @@ public struct DailyChallengeResultsView: View { } extension ApiClient { + @Sendable func loadDailyChallengeResults( gameMode: GameMode, timeScope gameNumber: DailyChallenge.GameNumber? - ) -> Effect { - self.apiRequest( - route: .dailyChallenge( - .results( - .fetch( - gameMode: gameMode, - gameNumber: gameNumber, - language: .en + ) async throws -> ResultEnvelope { + try await ResultEnvelope( + self.apiRequest( + route: .dailyChallenge( + .results( + .fetch( + gameMode: gameMode, + gameNumber: gameNumber, + language: .en + ) ) - ) - ), - as: FetchDailyChallengeResultsResponse.self + ), + as: FetchDailyChallengeResultsResponse.self + ) ) - .map(ResultEnvelope.init) } } diff --git a/Sources/DailyChallengeFeature/DailyChallengeView.swift b/Sources/DailyChallengeFeature/DailyChallengeView.swift index cb3fa3f1..994e34df 100644 --- a/Sources/DailyChallengeFeature/DailyChallengeView.swift +++ b/Sources/DailyChallengeFeature/DailyChallengeView.swift @@ -60,13 +60,13 @@ public enum DailyChallengeAction: Equatable { case dailyChallengeResults(DailyChallengeResultsAction) case delegate(DelegateAction) case dismissAlert - case fetchTodaysDailyChallengeResponse(Result<[FetchTodaysDailyChallengeResponse], ApiError>) + case fetchTodaysDailyChallengeResponse(TaskResult<[FetchTodaysDailyChallengeResponse]>) case gameButtonTapped(GameMode) - case onAppear case notificationButtonTapped case notificationsAuthAlert(NotificationsAuthAlertAction) case setNavigation(tag: DailyChallengeState.Route.Tag?) - case startDailyChallengeResponse(Result) + case startDailyChallengeResponse(TaskResult) + case task case userNotificationSettingsResponse(UserNotificationClient.Notification.Settings) public enum DelegateAction: Equatable { @@ -106,7 +106,7 @@ public let dailyChallengeReducer = Reducer< ._pullback( state: (\DailyChallengeState.route).appending(path: /DailyChallengeState.Route.results), action: /DailyChallengeAction.dailyChallengeResults, - environment: { .init(apiClient: $0.apiClient, mainQueue: $0.mainQueue) } + environment: { .init(apiClient: $0.apiClient) } ), notificationsAuthAlertReducer @@ -123,7 +123,9 @@ public let dailyChallengeReducer = Reducer< } ), - .init { state, action, environment in + Reducer< + DailyChallengeState, DailyChallengeAction, DailyChallengeEnvironment + > { state, action, environment in switch action { case .dailyChallengeResults: return .none @@ -164,29 +166,18 @@ public let dailyChallengeReducer = Reducer< state.gameModeIsLoading = challenge.dailyChallenge.gameMode - return startDailyChallenge( - challenge, - apiClient: environment.apiClient, - date: { environment.mainRunLoop.now.date }, - fileClient: environment.fileClient, - mainRunLoop: environment.mainRunLoop - ) - .catchToEffect(DailyChallengeAction.startDailyChallengeResponse) - - case .onAppear: - return .merge( - environment.apiClient.apiRequest( - route: .dailyChallenge(.today(language: .en)), - as: [FetchTodaysDailyChallengeResponse].self + return .task { + await .startDailyChallengeResponse( + TaskResult { + try await startDailyChallengeAsync( + challenge, + apiClient: environment.apiClient, + date: { environment.mainRunLoop.now.date }, + fileClient: environment.fileClient + ) + } ) - .receive(on: environment.mainRunLoop.animation()) - .catchToEffect(DailyChallengeAction.fetchTodaysDailyChallengeResponse), - - environment.userNotifications.getNotificationSettings - .receive(on: environment.mainRunLoop) - .map(DailyChallengeAction.userNotificationSettingsResponse) - .eraseToEffect() - ) + } case .notificationButtonTapped: state.notificationsAuthAlert = .init() @@ -212,19 +203,49 @@ public let dailyChallengeReducer = Reducer< state.route = nil return .none - case let .startDailyChallengeResponse(.failure(.alreadyPlayed(endsAt))): + case let .startDailyChallengeResponse(.failure(DailyChallengeError.alreadyPlayed(endsAt))): state.alert = .alreadyPlayed(nextStartsAt: endsAt) state.gameModeIsLoading = nil return .none - case let .startDailyChallengeResponse(.failure(.couldNotFetch(nextStartsAt))): + case let .startDailyChallengeResponse(.failure(DailyChallengeError.couldNotFetch(nextStartsAt))): state.alert = .couldNotFetchDaily(nextStartsAt: nextStartsAt) state.gameModeIsLoading = nil return .none + case .startDailyChallengeResponse(.failure): + return .none + case let .startDailyChallengeResponse(.success(inProgressGame)): state.gameModeIsLoading = nil - return .init(value: .delegate(.startGame(inProgressGame))) + return .task { .delegate(.startGame(inProgressGame)) } + + case .task: + return .run { send in + await withTaskGroup(of: Void.self) { group in + group.addTask { + await send( + .userNotificationSettingsResponse( + environment.userNotifications.getNotificationSettings() + ) + ) + } + + group.addTask { + await send( + .fetchTodaysDailyChallengeResponse( + TaskResult { + try await environment.apiClient.apiRequest( + route: .dailyChallenge(.today(language: .en)), + as: [FetchTodaysDailyChallengeResponse].self + ) + } + ), + animation: .default + ) + } + } + } case let .userNotificationSettingsResponse(settings): state.userNotificationSettings = settings @@ -395,7 +416,7 @@ public struct DailyChallengeView: View { .foregroundColor((self.colorScheme == .dark ? .isowordsBlack : .dailyChallenge)) .background(self.colorScheme == .dark ? Color.dailyChallenge : .isowordsBlack) } - .onAppear { self.viewStore.send(.onAppear) } + .task { await self.viewStore.send(.task).finish() } .alert(self.store.scope(state: \.alert), dismiss: .dismissAlert) .navigationStyle( backgroundColor: self.colorScheme == .dark ? .isowordsBlack : .dailyChallenge, @@ -513,8 +534,9 @@ private struct RingEffect: GeometryEffect { remoteNotifications: .noop, userNotifications: .noop ) - environment.userNotifications.getNotificationSettings = .init( - value: .init(authorizationStatus: .notDetermined)) + environment.userNotifications.getNotificationSettings = { + .init(authorizationStatus: .notDetermined) + } return Preview { NavigationView { diff --git a/Sources/DailyChallengeHelpers/DailyChallengeHelpers.swift b/Sources/DailyChallengeHelpers/DailyChallengeHelpers.swift index 9f509046..0efc5241 100644 --- a/Sources/DailyChallengeHelpers/DailyChallengeHelpers.swift +++ b/Sources/DailyChallengeHelpers/DailyChallengeHelpers.swift @@ -11,43 +11,40 @@ public enum DailyChallengeError: Error, Equatable { case couldNotFetch(nextStartsAt: Date) } -public func startDailyChallenge( +public func startDailyChallengeAsync( _ challenge: FetchTodaysDailyChallengeResponse, apiClient: ApiClient, date: @escaping () -> Date, - fileClient: FileClient, - mainRunLoop: AnySchedulerOf -) -> Effect { - guard challenge.yourResult.rank == nil else { - return Effect(error: .alreadyPlayed(endsAt: challenge.dailyChallenge.endsAt)) + fileClient: FileClient +) async throws -> InProgressGame { + guard challenge.yourResult.rank == nil + else { + throw DailyChallengeError.alreadyPlayed(endsAt: challenge.dailyChallenge.endsAt) } - return Effect.concatenate( - challenge.dailyChallenge.gameMode == .unlimited - ? fileClient - .loadSavedGames() - .tryMap { try $0.get() } - .ignoreFailure(setFailureType: DailyChallengeError.self) - .compactMap(\.dailyChallengeUnlimited) - .eraseToEffect() - : .none, - apiClient - .apiRequest( - route: .dailyChallenge( - .start( - gameMode: challenge.dailyChallenge.gameMode, - language: challenge.dailyChallenge.language - ) + guard + challenge.dailyChallenge.gameMode == .unlimited, + let game = try? await fileClient.loadSavedGames().dailyChallengeUnlimited + else { + do { + return try await InProgressGame( + response: apiClient.apiRequest( + route: .dailyChallenge( + .start( + gameMode: challenge.dailyChallenge.gameMode, + language: challenge.dailyChallenge.language + ) + ), + as: StartDailyChallengeResponse.self ), - as: StartDailyChallengeResponse.self + date: date() ) - .map { InProgressGame(response: $0, date: date()) } - .mapError { err in .couldNotFetch(nextStartsAt: challenge.dailyChallenge.endsAt) } - .eraseToEffect() - ) - .prefix(1) - .receive(on: mainRunLoop) - .eraseToEffect() + + } catch { + throw DailyChallengeError.couldNotFetch(nextStartsAt: challenge.dailyChallenge.endsAt) + } + } + return game } extension InProgressGame { diff --git a/Sources/DatabaseClient/Mocks.swift b/Sources/DatabaseClient/Mocks.swift index d2bc65bc..3dfda885 100644 --- a/Sources/DatabaseClient/Mocks.swift +++ b/Sources/DatabaseClient/Mocks.swift @@ -4,75 +4,75 @@ import XCTestDynamicOverlay #if DEBUG extension DatabaseClient { - public static let failing = Self( + public static let unimplemented = Self( completeDailyChallenge: { _, _ in - .failing("\(Self.self).completeDailyChallenge is unimplemented") + .unimplemented("\(Self.self).completeDailyChallenge") }, createTodaysDailyChallenge: { _ in - .failing("\(Self.self).createTodaysDailyChallenge is unimplemented") + .unimplemented("\(Self.self).createTodaysDailyChallenge") }, fetchActiveDailyChallengeArns: { - .failing("\(Self.self).fetchActiveDailyChallengeArns is unimplemented") + .unimplemented("\(Self.self).fetchActiveDailyChallengeArns") }, - fetchAppleReceipt: { _ in .failing("\(Self.self).fetchAppleReceipt is unimplemented") }, + fetchAppleReceipt: { _ in .unimplemented("\(Self.self).fetchAppleReceipt") }, fetchDailyChallengeById: { _ in - .failing("\(Self.self).fetchDailyChallengeById is unimplemented") + .unimplemented("\(Self.self).fetchDailyChallengeById") }, fetchDailyChallengeHistory: { _ in - .failing("\(Self.self).fetchDailyChallengeHistory is unimplemented") + .unimplemented("\(Self.self).fetchDailyChallengeHistory") }, fetchDailyChallengeReport: { _ in - .failing("\(Self.self).fetchDailyChallengeReport is unimplemented") + .unimplemented("\(Self.self).fetchDailyChallengeReport") }, fetchDailyChallengeResult: { _ in - .failing("\(Self.self).fetchDailyChallengeResult is unimplemented") + .unimplemented("\(Self.self).fetchDailyChallengeResult") }, fetchDailyChallengeResults: { _ in - .failing("\(Self.self).fetchDailyChallengeResults is unimplemented") + .unimplemented("\(Self.self).fetchDailyChallengeResults") }, fetchLeaderboardSummary: { _ in - .failing("\(Self.self).fetchLeaderboardSummary is unimplemented") + .unimplemented("\(Self.self).fetchLeaderboardSummary") }, fetchLeaderboardWeeklyRanks: { _, _ in - .failing("\(Self.self).fetchLeaderboardWeeklyRanks is unimplemented") + .unimplemented("\(Self.self).fetchLeaderboardWeeklyRanks") }, fetchLeaderboardWeeklyWord: { _, _ in - .failing("\(Self.self).fetchLeaderboardWeeklyWord is unimplemented") + .unimplemented("\(Self.self).fetchLeaderboardWeeklyWord") }, fetchPlayerByAccessToken: { _ in - .failing("\(Self.self).fetchPlayerByAccessToken is unimplemented") + .unimplemented("\(Self.self).fetchPlayerByAccessToken") }, - fetchPlayerByDeviceId: { _ in .failing("\(Self.self).fetchPlayerByDeviceId is unimplemented") + fetchPlayerByDeviceId: { _ in .unimplemented("\(Self.self).fetchPlayerByDeviceId") }, fetchPlayerByGameCenterLocalPlayerId: { _ in - .failing("\(Self.self).fetchPlayerByGameCenterLocalPlayerId is unimplemented") + .unimplemented("\(Self.self).fetchPlayerByGameCenterLocalPlayerId") }, fetchRankedLeaderboardScores: { _ in - .failing("\(Self.self).fetchRankedLeaderboardScores is unimplemented") + .unimplemented("\(Self.self).fetchRankedLeaderboardScores") }, - fetchSharedGame: { _ in .failing("\(Self.self).fetchSharedGame is unimplemented") }, + fetchSharedGame: { _ in .unimplemented("\(Self.self).fetchSharedGame") }, fetchTodaysDailyChallenges: { _ in - .failing("\(Self.self).fetchTodaysDailyChallenges is unimplemented") + .unimplemented("\(Self.self).fetchTodaysDailyChallenges") }, fetchVocabLeaderboard: { _, _, _ in - .failing("\(Self.self).fetchVocabLeaderboard is unimplemented") + .unimplemented("\(Self.self).fetchVocabLeaderboard") }, fetchVocabLeaderboardWord: { _ in - .failing("\(Self.self).fetchVocabLeaderboardWord is unimplemented") + .unimplemented("\(Self.self).fetchVocabLeaderboardWord") }, - insertPlayer: { _ in .failing("\(Self.self).insertPlayer is unimplemented") }, - insertPushToken: { _ in .failing("\(Self.self).insertPushToken is unimplemented") }, - insertSharedGame: { _, _ in .failing("\(Self.self).insertSharedGame is unimplemented") }, - migrate: { .failing("\(Self.self).migrate is unimplemented") }, - shutdown: { .failing("\(Self.self).shutdown is unimplemented") }, - startDailyChallenge: { _, _ in .failing("\(Self.self).startDailyChallenge is unimplemented") + insertPlayer: { _ in .unimplemented("\(Self.self).insertPlayer") }, + insertPushToken: { _ in .unimplemented("\(Self.self).insertPushToken") }, + insertSharedGame: { _, _ in .unimplemented("\(Self.self).insertSharedGame") }, + migrate: { .unimplemented("\(Self.self).migrate") }, + shutdown: { .unimplemented("\(Self.self).shutdown") }, + startDailyChallenge: { _, _ in .unimplemented("\(Self.self).startDailyChallenge") }, submitLeaderboardScore: { _ in - .failing("\(Self.self).submitLeaderboardScore is unimplemented") + .unimplemented("\(Self.self).submitLeaderboardScore") }, - updateAppleReceipt: { _, _ in .failing("\(Self.self).updateAppleReceipt is unimplemented") }, - updatePlayer: { _ in .failing("\(Self.self).updatePlayer is unimplemented") }, - updatePushSetting: { _, _, _ in .failing("\(Self.self).updatePushSetting is unimplemented") } + updateAppleReceipt: { _, _ in .unimplemented("\(Self.self).updateAppleReceipt") }, + updatePlayer: { _ in .unimplemented("\(Self.self).updatePlayer") }, + updatePushSetting: { _, _, _ in .unimplemented("\(Self.self).updatePushSetting") } ) } #endif diff --git a/Sources/DatabaseLive/DatabaseLive.swift b/Sources/DatabaseLive/DatabaseLive.swift index e25207c0..53f33d3a 100644 --- a/Sources/DatabaseLive/DatabaseLive.swift +++ b/Sources/DatabaseLive/DatabaseLive.swift @@ -662,15 +662,12 @@ extension DatabaseClient { migrate: { () -> EitherIO in let database = pool.database(logger: Logger(label: "Postgres")) return sequence([ - database.run( - #"CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "heroku_ext""# - ), - database.run( - #"CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "heroku_ext""# - ), - database.run( - #"CREATE EXTENSION IF NOT EXISTS "citext" WITH SCHEMA "heroku_ext""# - ), + database.run(#"CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "heroku_ext""#) + .catch { _ in database.run(#"CREATE EXTENSION IF NOT EXISTS "pgcrypto""#) }, + database.run(#"CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "heroku_ext""#) + .catch { _ in database.run(#"CREATE EXTENSION IF NOT EXISTS "uuid-ossp""#) }, + database.run(#"CREATE EXTENSION IF NOT EXISTS "citext" WITH SCHEMA "heroku_ext""#) + .catch { _ in database.run(#"CREATE EXTENSION IF NOT EXISTS "citext""#) }, database.run( """ CREATE OR REPLACE FUNCTION CURRENT_DAILY_CHALLENGE_NUMBER() RETURNS integer AS $$ diff --git a/Sources/DemoFeature/Demo.swift b/Sources/DemoFeature/Demo.swift index b1b994c6..a5afc797 100644 --- a/Sources/DemoFeature/Demo.swift +++ b/Sources/DemoFeature/Demo.swift @@ -144,7 +144,7 @@ public let demoReducer = Reducer.combine mainRunLoop: $0.mainRunLoop, remoteNotifications: .noop, serverConfig: .noop, - setUserInterfaceStyle: { _ in .none }, + setUserInterfaceStyle: { _ in }, storeKit: .noop, userDefaults: .noop, userNotifications: .noop @@ -160,11 +160,9 @@ public let demoReducer = Reducer.combine return .none case .fullVersionButtonTapped: - return environment.applicationClient.open( - ServerConfig().appStoreUrl, - [:] - ) - .fireAndForget() + return .fireAndForget { + _ = await environment.applicationClient.open(ServerConfig().appStoreUrl, [:]) + } case .game(.gameOver(.submitGameResponse(.success))): state.appStoreOverlayIsPresented = true @@ -178,8 +176,9 @@ public let demoReducer = Reducer.combine return .none case .onAppear: - return environment.audioPlayer.load(AudioPlayerClient.Sound.allCases) - .fireAndForget() + return .fireAndForget { + await environment.audioPlayer.load(AudioPlayerClient.Sound.allCases) + } case .onboarding(.delegate(.getStarted)): state.step = .game( @@ -199,10 +198,11 @@ public let demoReducer = Reducer.combine } } ) -.onChange(of: { $0.game?.gameOver != nil }) { isGameOver, state, _, environment in - Effect(value: .gameOverDelay) - .delay(for: 2, scheduler: environment.mainQueue) - .eraseToEffect() +.onChange(of: { $0.game?.gameOver != nil }) { _, _, _, environment in + .task { + try await environment.mainQueue.sleep(for: .seconds(2)) + return .gameOverDelay + } } public struct DemoView: View { diff --git a/Sources/DeviceId/DeviceId.swift b/Sources/DeviceId/DeviceId.swift index 750572c9..bb9997b9 100644 --- a/Sources/DeviceId/DeviceId.swift +++ b/Sources/DeviceId/DeviceId.swift @@ -25,11 +25,8 @@ extension DeviceIdentifier { import XCTestDynamicOverlay extension DeviceIdentifier { - public static let failing = Self( - id: { - XCTFail("\(Self.self).id is unimplemented") - return UUID() - } + public static let unimplemented = Self( + id: XCTUnimplemented("\(Self.self).id", placeholder: UUID()) ) public static let noop = Self( diff --git a/Sources/DictionaryClient/Mocks.swift b/Sources/DictionaryClient/Mocks.swift index ec3e043a..956070d3 100644 --- a/Sources/DictionaryClient/Mocks.swift +++ b/Sources/DictionaryClient/Mocks.swift @@ -15,24 +15,12 @@ extension DictionaryClient { import XCTestDynamicOverlay extension DictionaryClient { - public static let failing = Self( - contains: { _, _ in - XCTFail("\(Self.self).contains is unimplemented") - return false - }, - load: { _ in - XCTFail("\(Self.self).load is unimplemented") - return false - }, - lookup: { _, _ in - XCTFail("\(Self.self).lookup is unimplemented") - return nil - }, - randomCubes: { _ in - XCTFail("\(Self.self).randomCubes is unimplemented") - return .mock - }, - unload: { _ in XCTFail("\(Self.self).unload is unimplemented") } + public static let unimplemented = Self( + contains: XCTUnimplemented("\(Self.self).contains", placeholder: false), + load: XCTUnimplemented("\(Self.self).load", placeholder: false), + lookup: XCTUnimplemented("\(Self.self).lookup"), + randomCubes: XCTUnimplemented("\(Self.self).randomCubes", placeholder: .mock), + unload: XCTUnimplemented("\(Self.self).unload") ) } #endif diff --git a/Sources/FeedbackGeneratorClient/Client.swift b/Sources/FeedbackGeneratorClient/Client.swift index c835e0b4..5b106ae2 100644 --- a/Sources/FeedbackGeneratorClient/Client.swift +++ b/Sources/FeedbackGeneratorClient/Client.swift @@ -1,6 +1,6 @@ import ComposableArchitecture public struct FeedbackGeneratorClient { - public var prepare: () -> Effect - public var selectionChanged: () -> Effect + public var prepare: @Sendable () async -> Void + public var selectionChanged: @Sendable () async -> Void } diff --git a/Sources/FeedbackGeneratorClient/Live.swift b/Sources/FeedbackGeneratorClient/Live.swift index b13439d2..e15f0c1a 100644 --- a/Sources/FeedbackGeneratorClient/Live.swift +++ b/Sources/FeedbackGeneratorClient/Live.swift @@ -1,36 +1,12 @@ import ComposableArchitecture - -#if canImport(UIKit) - import UIKit -#endif -#if canImport(AppKit) - import AppKit -#endif +import UIKit extension FeedbackGeneratorClient { public static var live: Self { - #if canImport(UIKit) - let generator = UISelectionFeedbackGenerator() - return Self( - prepare: { - .fireAndForget { generator.prepare() } - }, - selectionChanged: { - .fireAndForget { generator.selectionChanged() } - } - ) - #else - let generator = NSHapticFeedbackManager.defaultPerformer - return Self( - prepare: { - .fireAndForget {} - }, - selectionChanged: { - .fireAndForget { - generator.perform(.levelChange, performanceTime: .default) - } - } - ) - #endif + let generator = UISelectionFeedbackGenerator() + return Self( + prepare: { await generator.prepare() }, + selectionChanged: { await generator.selectionChanged() } + ) } } diff --git a/Sources/FeedbackGeneratorClient/Mocks.swift b/Sources/FeedbackGeneratorClient/Mocks.swift index 820d8dca..20e37984 100644 --- a/Sources/FeedbackGeneratorClient/Mocks.swift +++ b/Sources/FeedbackGeneratorClient/Mocks.swift @@ -1,15 +1,15 @@ -import XCTestDebugSupport +import XCTestDynamicOverlay extension FeedbackGeneratorClient { #if DEBUG - public static let failing = Self( - prepare: { .failing("\(Self.self).prepare is unimplemented") }, - selectionChanged: { .failing("\(Self.self).selectionChanged is unimplemented") } + public static let unimplemented = Self( + prepare: XCTUnimplemented("\(Self.self).prepare"), + selectionChanged: XCTUnimplemented("\(Self.self).selectionChanged") ) #endif public static let noop = Self( - prepare: { .none }, - selectionChanged: { .none } + prepare: {}, + selectionChanged: {} ) } diff --git a/Sources/FileClient/Client.swift b/Sources/FileClient/Client.swift index f4525f58..d2eefb69 100644 --- a/Sources/FileClient/Client.swift +++ b/Sources/FileClient/Client.swift @@ -4,27 +4,15 @@ import ComposableArchitecture import Foundation public struct FileClient { - public var delete: (String) -> Effect - public var load: (String) -> Effect - public var save: (String, Data) -> Effect + public var delete: @Sendable (String) async throws -> Void + public var load: @Sendable (String) async throws -> Data + public var save: @Sendable (String, Data) async throws -> Void - public func load( - _ type: A.Type, from fileName: String - ) -> Effect, Never> { - self.load(fileName) - .decode(type: A.self, decoder: JSONDecoder()) - .mapError { $0 as NSError } - .catchToEffect() + public func load(_ type: A.Type, from fileName: String) async throws -> A { + try await JSONDecoder().decode(A.self, from: self.load(fileName)) } - public func save( - _ data: A, to fileName: String, on queue: AnySchedulerOf - ) -> Effect { - Just(data) - .subscribe(on: queue) - .encode(encoder: JSONEncoder()) - .flatMap { data in self.save(fileName, data) } - .ignoreFailure() - .eraseToEffect() + public func save(_ data: A, to fileName: String) async throws -> Void { + try await self.save(fileName, JSONEncoder().encode(data)) } } diff --git a/Sources/FileClient/FileClientEffects.swift b/Sources/FileClient/FileClientEffects.swift index 0d87fc0d..d2ab8bbb 100644 --- a/Sources/FileClient/FileClientEffects.swift +++ b/Sources/FileClient/FileClientEffects.swift @@ -3,14 +3,12 @@ import Combine import ComposableArchitecture extension FileClient { - public func loadSavedGames() -> Effect, Never> { - self.load(SavedGamesState.self, from: savedGamesFileName) + public func loadSavedGames() async throws -> SavedGamesState { + try await self.load(SavedGamesState.self, from: savedGamesFileName) } - public func saveGames( - games: SavedGamesState, on queue: AnySchedulerOf - ) -> Effect { - self.save(games, to: savedGamesFileName, on: queue) + public func save(games: SavedGamesState) async throws { + try await self.save(games, to: savedGamesFileName) } } diff --git a/Sources/FileClient/Live.swift b/Sources/FileClient/Live.swift index 92a20b5b..a0a9d2a5 100644 --- a/Sources/FileClient/Live.swift +++ b/Sources/FileClient/Live.swift @@ -7,35 +7,20 @@ extension FileClient { .first! return Self( - delete: { fileName in - .fireAndForget { - try? FileManager.default.removeItem( - at: - documentDirectory - .appendingPathComponent(fileName) - .appendingPathExtension("json") - ) - } + delete: { + try FileManager.default.removeItem( + at: documentDirectory.appendingPathComponent($0).appendingPathExtension("json") + ) }, - load: { fileName in - .catching { - try Data( - contentsOf: - documentDirectory - .appendingPathComponent(fileName) - .appendingPathExtension("json") - ) - } + load: { + try Data( + contentsOf: documentDirectory.appendingPathComponent($0).appendingPathExtension("json") + ) }, - save: { fileName, data in - .fireAndForget { - _ = try? data.write( - to: - documentDirectory - .appendingPathComponent(fileName) - .appendingPathExtension("json") - ) - } + save: { + try $1.write( + to: documentDirectory.appendingPathComponent($0).appendingPathExtension("json") + ) } ) } diff --git a/Sources/FileClient/Mocks.swift b/Sources/FileClient/Mocks.swift index 469d1e1b..e2417f43 100644 --- a/Sources/FileClient/Mocks.swift +++ b/Sources/FileClient/Mocks.swift @@ -5,30 +5,27 @@ import XCTestDynamicOverlay extension FileClient { public static let noop = Self( - delete: { _ in .none }, - load: { _ in .none }, - save: { _, _ in .none } + delete: { _ in }, + load: { _ in throw CancellationError() }, + save: { _, _ in } ) #if DEBUG - public static let failing = Self( - delete: { .failing("\(Self.self).delete(\($0)) is unimplemented") }, - load: { .failing("\(Self.self).load(\($0)) is unimplemented") }, - save: { file, _ in .failing("\(Self.self).save(\(file)) is unimplemented") } + public static let unimplemented = Self( + delete: XCTUnimplemented("\(Self.self).deleteAsync"), + load: XCTUnimplemented("\(Self.self).loadAsync"), + save: XCTUnimplemented("\(Self.self).saveAsync") ) #endif - public mutating func override( - load file: String, _ data: Effect - ) - where A: Encodable { + public mutating func override(load file: String, _ data: A) { let fulfill = expectation(description: "FileClient.load(\(file))") - self.load = { [self] in + self.load = { @Sendable [self] in if $0 == file { fulfill() - return data.tryMap { try JSONEncoder().encode($0) }.eraseToEffect() + return try JSONEncoder().encode(data) } else { - return self.load($0) + return try await load($0) } } } diff --git a/Sources/GameCore/Drawer.swift b/Sources/GameCore/Drawer.swift index 5527ae6b..099468c7 100644 --- a/Sources/GameCore/Drawer.swift +++ b/Sources/GameCore/Drawer.swift @@ -3,17 +3,27 @@ import ComposableArchitecture extension Reducer where State == GameState, Action == GameAction, Environment == GameEnvironment { static let activeGamesTray = Self { state, action, environment in - let activeGameEffects = Effect.merge( - environment.gameCenter.turnBasedMatch.loadMatches() - .receive(on: environment.mainQueue.animation()) - .mapError { $0 as NSError } - .catchToEffect(GameAction.matchesLoaded), - environment.fileClient.loadSavedGames() - .subscribe(on: environment.backgroundQueue) - .receive(on: environment.mainQueue.animation()) - .eraseToEffect() - .map(GameAction.savedGamesLoaded) - ) + let activeGameEffects = Effect.run { send in + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await send( + .matchesLoaded( + TaskResult { try await environment.gameCenter.turnBasedMatch.loadMatches() } + ), + animation: .default + ) + } + + group.addTask { + await send( + .savedGamesLoaded( + TaskResult { try await environment.fileClient.loadSavedGames() } + ), + animation: .default + ) + } + } + } switch action { case .cancelButtonTapped, @@ -52,7 +62,7 @@ extension Reducer where State == GameState, Action == GameAction, Environment == ) return .none - case .onAppear: + case .task: return activeGameEffects case let .savedGamesLoaded(.success(savedGames)): diff --git a/Sources/GameCore/GameCore.swift b/Sources/GameCore/GameCore.swift index 15389a53..9082f9da 100644 --- a/Sources/GameCore/GameCore.swift +++ b/Sources/GameCore/GameCore.swift @@ -7,7 +7,6 @@ import CasePaths import ClientModels import ComposableArchitecture import ComposableGameCenter -import ComposableGameCenterHelpers import ComposableStoreKit import ComposableUserNotifications import CubeCore @@ -161,11 +160,11 @@ public enum GameAction: Equatable { case gameLoaded case gameOver(GameOverAction) case lowPowerModeChanged(Bool) - case matchesLoaded(Result<[TurnBasedMatch], NSError>) + case matchesLoaded(TaskResult<[TurnBasedMatch]>) case menuButtonTapped - case onAppear + case task case pan(UIGestureRecognizer.State, PanData?) - case savedGamesLoaded(Result) + case savedGamesLoaded(TaskResult) case settingsButtonTapped case submitButtonTapped(reaction: Move.Reaction?) case tap(UIGestureRecognizer.State, IndexedCubeFace?) @@ -182,7 +181,7 @@ public enum GameAction: Equatable { public enum GameCenterAction: Equatable { case listener(LocalPlayerClient.ListenerEvent) - case turnBasedMatchResponse(Result) + case turnBasedMatchResponse(TaskResult) } } @@ -202,7 +201,7 @@ public struct GameEnvironment { public var mainRunLoop: AnySchedulerOf public var remoteNotifications: RemoteNotificationsClient public var serverConfig: ServerConfigClient - public var setUserInterfaceStyle: (UIUserInterfaceStyle) -> Effect + public var setUserInterfaceStyle: @Sendable (UIUserInterfaceStyle) async -> Void public var storeKit: StoreKitClient public var userDefaults: UserDefaultsClient public var userNotifications: UserNotificationClient @@ -223,7 +222,7 @@ public struct GameEnvironment { mainRunLoop: AnySchedulerOf, remoteNotifications: RemoteNotificationsClient, serverConfig: ServerConfigClient, - setUserInterfaceStyle: @escaping (UIUserInterfaceStyle) -> Effect, + setUserInterfaceStyle: @escaping @Sendable (UIUserInterfaceStyle) async -> Void, storeKit: StoreKitClient, userDefaults: UserDefaultsClient, userNotifications: UserNotificationClient @@ -254,10 +253,6 @@ public struct GameEnvironment { } } -private enum InterstitialId {} -private enum LowPowerModeId {} -private enum TimerId {} - public func gameReducer( state: StatePath, action: CasePath, @@ -316,8 +311,26 @@ where StatePath: TcaHelpers.Path, StatePath.Value == GameState { else { return .none } return .merge( - forceQuitMatch(match: match, gameCenter: environment.gameCenter) - .fireAndForget(), + .fireAndForget { + let localPlayer = environment.gameCenter.localPlayer.localPlayer() + let currentParticipantIsLocalPlayer = + match.currentParticipant?.player?.gamePlayerId == localPlayer.gamePlayerId + + if currentParticipantIsLocalPlayer { + try await environment.gameCenter.turnBasedMatch.endMatchInTurn( + .init( + for: match.matchId, + matchData: match.matchData ?? Data(), + localPlayerId: localPlayer.gamePlayerId, + localPlayerMatchOutcome: .quit, + message: "\(localPlayer.displayName) forfeited the match." + ) + ) + } else { + try await environment.gameCenter.turnBasedMatch + .participantQuitOutOfTurn(match.matchId) + } + }, state.gameOver(environment: environment) ) @@ -347,8 +360,7 @@ where StatePath: TcaHelpers.Path, StatePath.Value == GameState { return state.gameOver(environment: environment) case .exitButtonTapped: - return Effect.gameTearDownEffects(audioPlayer: environment.audioPlayer) - .fireAndForget() + return .none case .forfeitGameButtonTapped: state.alert = .init( @@ -357,7 +369,8 @@ where StatePath: TcaHelpers.Path, StatePath.Value == GameState { """ Forfeiting will end the game and your opponent will win. Are you sure you want to \ forfeit? - """), + """ + ), primaryButton: .default(.init("Don’t forfeit"), action: .send(.dontForfeitButtonTapped)), secondaryButton: .destructive(.init("Yes, forfeit"), action: .send(.forfeitButtonTapped)) ) @@ -368,13 +381,14 @@ where StatePath: TcaHelpers.Path, StatePath.Value == GameState { case .gameLoaded: state.isGameLoaded = true - return Effect - .timer(id: TimerId.self, every: 1, on: environment.mainRunLoop) - .map { GameAction.timerTick($0.date) } + return .run { send in + for await instant in environment.mainRunLoop.timer(interval: .seconds(1)) { + await send(.timerTick(instant.date)) + } + } case .gameOver(.delegate(.close)): - return Effect.gameTearDownEffects(audioPlayer: environment.audioPlayer) - .fireAndForget() + return .none case let .gameOver(.delegate(.startGame(inProgressGame))): state = .init(inProgressGame: inProgressGame) @@ -394,13 +408,45 @@ where StatePath: TcaHelpers.Path, StatePath.Value == GameState { state.bottomMenu = .gameMenu(state: state) return .none - case .onAppear: + case .task: guard !state.isGameOver else { return .none } state.gameCurrentTime = environment.date() - return .onAppearEffects( - environment: environment, - gameContext: state.gameContext - ) + + return .run { [gameContext = state.gameContext] send in + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + for await isLowPower in await environment.lowPowerMode.start() { + await send(.lowPowerModeChanged(isLowPower)) + } + } + + if gameContext.isTurnBased { + group.addTask { + let playedGamesCount = await environment.userDefaults + .incrementMultiplayerOpensCount() + let isFullGamePurchased = environment.apiClient.currentPlayer()?.appleReceipt != nil + guard + !isFullGamePurchased, + shouldShowInterstitial( + gamePlayedCount: playedGamesCount, + gameContext: .init(gameContext: gameContext), + serverConfig: environment.serverConfig.config() + ) + else { return } + try await environment.mainRunLoop.sleep(for: .seconds(3)) + await send(.delayedShowUpgradeInterstitial, animation: .default) + } + } + + group.addTask { + try await environment.mainQueue.sleep(for: 0.5) + await send(.gameLoaded) + } + } + for music in AudioPlayerClient.Sound.allMusic { + await environment.audioPlayer.stop(music) + } + } case .pan(.began, _): state.isPanning = true @@ -643,7 +689,7 @@ extension GameState { // Don't show menu for timed games. guard self.gameMode != .timed - else { return .init(value: .confirmRemoveCube(index)) } + else { return .task { .confirmRemoveCube(index) } } let isTurnEndingRemoval: Bool if let turnBasedMatch = self.turnBasedContext, @@ -657,7 +703,8 @@ extension GameState { } self.bottomMenu = .removeCube( - index: index, state: self, isTurnEndingRemoval: isTurnEndingRemoval) + index: index, state: self, isTurnEndingRemoval: isTurnEndingRemoval + ) return .none } @@ -687,8 +734,6 @@ extension GameState { with reaction: Move.Reaction?, environment: GameEnvironment ) -> Effect { - let soundEffects: Effect - let move = Move( playedAt: environment.mainRunLoop.now.date, playerIndex: self.turnBasedContext?.localPlayerIndex, @@ -705,30 +750,23 @@ extension GameState { previousMoves: self.moves ) - if result != nil { - self.moves.append(move) - soundEffects = .merge( - self - .selectedWord.compactMap { !self.cubes[$0.index].isInPlay ? $0.index : nil } - .map { index in - environment.audioPlayer - .play(.cubeRemove) - .deferred( - for: .milliseconds(removeCubeDelay(index: index)), - scheduler: environment.mainQueue - ) - .fireAndForget() - } - ) - } else { - soundEffects = .none - } + defer { self.selectedWord = [] } - self.selectedWord = [] + guard result != nil else { return .none } - return - soundEffects - .fireAndForget() + self.moves.append(move) + + return .fireAndForget { [self] in + await withThrowingTaskGroup(of: Void.self) { group in + for face in self.selectedWord where !self.cubes[face.index].isInPlay { + group.addTask { + try await environment.mainQueue + .sleep(for: .milliseconds(removeCubeDelay(index: face.index))) + await environment.audioPlayer.play(.cubeRemove) + } + } + } + } } mutating func gameOver(environment: GameEnvironment) -> Effect { @@ -739,14 +777,11 @@ extension GameState { isDemo: self.isDemo ) - let saveGameEffect: Effect = environment.database - .saveGame(.init(gameState: self)) - .receive(on: environment.mainQueue) - .fireAndForget() - switch self.gameContext { case .dailyChallenge, .shared, .solo: - return saveGameEffect + return .fireAndForget { [self] in + try await environment.database.saveGame(.init(gameState: self)) + } case let .turnBased(turnBasedMatch): self.gameOver?.turnBasedContext = turnBasedMatch @@ -904,28 +939,29 @@ func menuTitle(state: GameState) -> TextState { } #if DEBUG + import XCTestDynamicOverlay + extension GameEnvironment { - public static let failing = Self( - apiClient: .failing, - applicationClient: .failing, - audioPlayer: .failing, - backgroundQueue: .failing("backgroundQueue"), - build: .failing, - database: .failing, - dictionary: .failing, - feedbackGenerator: .failing, - fileClient: .failing, - gameCenter: .failing, - lowPowerMode: .failing, - mainQueue: .failing("mainQueue"), - mainRunLoop: .failing("mainRunLoop"), - remoteNotifications: .failing, - serverConfig: .failing, - setUserInterfaceStyle: { _ in .failing("\(Self.self).setUserInterfaceStyle is unimplemented") - }, - storeKit: .failing, - userDefaults: .failing, - userNotifications: .failing + public static let unimplemented = Self( + apiClient: .unimplemented, + applicationClient: .unimplemented, + audioPlayer: .unimplemented, + backgroundQueue: .unimplemented("backgroundQueue"), + build: .unimplemented, + database: .unimplemented, + dictionary: .unimplemented, + feedbackGenerator: .unimplemented, + fileClient: .unimplemented, + gameCenter: .unimplemented, + lowPowerMode: .unimplemented, + mainQueue: .unimplemented("mainQueue"), + mainRunLoop: .unimplemented("mainRunLoop"), + remoteNotifications: .unimplemented, + serverConfig: .unimplemented, + setUserInterfaceStyle: XCTUnimplemented("\(Self.self).setUserInterfaceStyle"), + storeKit: .unimplemented, + userDefaults: .unimplemented, + userNotifications: .unimplemented ) public static let noop = Self( @@ -944,7 +980,7 @@ func menuTitle(state: GameState) -> TextState { mainRunLoop: .immediate, remoteNotifications: .noop, serverConfig: .noop, - setUserInterfaceStyle: { _ in .none }, + setUserInterfaceStyle: { _ in }, storeKit: .noop, userDefaults: .noop, userNotifications: .noop @@ -952,44 +988,6 @@ func menuTitle(state: GameState) -> TextState { } #endif -extension Effect where Output == GameAction, Failure == Never { - static func onAppearEffects( - environment: GameEnvironment, - gameContext: ClientModels.GameContext - ) -> Self { - .merge( - environment.lowPowerMode.start - .receive(on: environment.mainQueue) - .eraseToEffect() - .map(GameAction.lowPowerModeChanged) - .cancellable(id: LowPowerModeId.self), - - Effect(value: .gameLoaded) - .delay(for: 0.5, scheduler: environment.mainQueue) - .eraseToEffect(), - - gameContext.isTurnBased - ? Effect.showUpgradeInterstitial( - gameContext: .init(gameContext: gameContext), - isFullGamePurchased: environment.apiClient.currentPlayer()?.appleReceipt != nil, - serverConfig: environment.serverConfig.config(), - playedGamesCount: { - environment.userDefaults.incrementMultiplayerOpensCount() - .setFailureType(to: Error.self) - .eraseToEffect() - } - ) - .filter { $0 } - .delay(for: 3, scheduler: environment.mainRunLoop.animation()) - .map { _ in GameAction.delayedShowUpgradeInterstitial } - .ignoreFailure() - .eraseToEffect() - .cancellable(id: InterstitialId.self) - : .none - ) - } -} - extension UpgradeInterstitialFeature.GameContext { fileprivate init(gameContext: ClientModels.GameContext) { switch gameContext { @@ -1005,20 +1003,6 @@ extension UpgradeInterstitialFeature.GameContext { } } -extension Effect where Output == Never, Failure == Never { - public static func gameTearDownEffects(audioPlayer: AudioPlayerClient) -> Self { - .merge( - .cancel(id: InterstitialId.self), - .cancel(id: ListenerId.self), - .cancel(id: LowPowerModeId.self), - .cancel(id: TimerId.self), - Effect - .merge(AudioPlayerClient.Sound.allMusic.map(audioPlayer.stop)) - .fireAndForget() - ) - } -} - extension Reducer where State == GameState, Action == GameAction, Environment == GameEnvironment { static let removingCubesWithDoubleTap: Self = Self { state, action, _ in guard @@ -1084,19 +1068,13 @@ extension Reducer where State == GameState, Action == GameAction, Environment == isDemo: state.isDemo, turnBasedContext: state.turnBasedContext ) - return .merge( - environment.gameCenter.turnBasedMatch.remove(match) - .fireAndForget(), - - environment.feedbackGenerator - .selectionChanged() - .fireAndForget() - ) + return .fireAndForget { + await environment.feedbackGenerator.selectionChanged() + try await environment.gameCenter.turnBasedMatch.remove(match) + } } - return environment.feedbackGenerator - .selectionChanged() - .fireAndForget() + return .fireAndForget { await environment.feedbackGenerator.selectionChanged() } case let .gameCenter(.turnBasedMatchResponse(.success(match))): guard @@ -1119,12 +1097,14 @@ extension Reducer where State == GameState, Action == GameAction, Environment == case .gameOver(.delegate(.close)), .exitButtonTapped: - return .cancel(id: ListenerId.self) + return .none - case .onAppear: - return environment.gameCenter.localPlayer.listener - .map { .gameCenter(.listener($0)) } - .cancellable(id: ListenerId.self) + case .task: + return .run { send in + for await event in environment.gameCenter.localPlayer.listener() { + await send(.gameCenter(.listener(event))) + } + } case .submitButtonTapped, .wordSubmitButton(.delegate(.confirmSubmit)), @@ -1141,81 +1121,73 @@ extension Reducer where State == GameState, Action == GameAction, Environment == playerId: environment.apiClient.currentPlayer()?.player.id ) let matchData = Data(turnBasedMatchData: turnBasedMatchData) - let reloadMatch = environment.gameCenter.turnBasedMatch.load(turnBasedContext.match.matchId) - .mapError { $0 as NSError } - .catchToEffect { GameAction.gameCenter(.turnBasedMatchResponse($0)) } - if state.isGameOver { - let completedGame = CompletedGame(gameState: state) - guard - let completedMatch = CompletedMatch( - completedGame: completedGame, - turnBasedContext: turnBasedContext - ) - else { return .none } - - return .concatenate( - environment.gameCenter.turnBasedMatch - .endMatchInTurn( - .init( - for: turnBasedContext.match.matchId, - matchData: matchData, - localPlayerId: turnBasedContext.localPlayer.gamePlayerId, - localPlayerMatchOutcome: completedMatch.yourOutcome, - message: "Game over! Let’s see how you did!" - ) - ) - .fireAndForget(), - - reloadMatch, - - environment.database.saveGame(completedGame) - .fireAndForget() - ) - } else { - switch move.type { - case .removedCube: - let shouldEndTurn = - state.moves.count > 1 - && state.moves[state.moves.count - 2].playerIndex == turnBasedContext.localPlayerIndex - - return .concatenate( - shouldEndTurn - ? environment.gameCenter.turnBasedMatch - .endTurn( - .init( - for: turnBasedContext.match.matchId, - matchData: matchData, - message: "\(turnBasedContext.localPlayer.displayName) removed cubes!" + return .task { [state] in + return await .gameCenter( + .turnBasedMatchResponse( + TaskResult { + if state.isGameOver { + let completedGame = CompletedGame(gameState: state) + if + let completedMatch = CompletedMatch( + completedGame: completedGame, + turnBasedContext: turnBasedContext ) - ) - .fireAndForget() - - : environment.gameCenter.turnBasedMatch - .saveCurrentTurn(turnBasedContext.match.matchId, matchData) - .fireAndForget(), - reloadMatch - ) - case let .playedWord(cubeFaces): - let word = state.cubes.string(from: cubeFaces) - let score = SharedModels.score(word) - let reaction = (move.reactions?.values.first).map { " \($0.rawValue)" } ?? "" - - return .concatenate( - environment.gameCenter.turnBasedMatch - .endTurn( - .init( - for: turnBasedContext.match.matchId, - matchData: matchData, - message: - "\(turnBasedContext.localPlayer.displayName) played \(word)! (+\(score)\(reaction))" - ) - ) - .fireAndForget(), - - reloadMatch + { + try await environment.gameCenter.turnBasedMatch.endMatchInTurn( + .init( + for: turnBasedContext.match.matchId, + matchData: matchData, + localPlayerId: turnBasedContext.localPlayer.gamePlayerId, + localPlayerMatchOutcome: completedMatch.yourOutcome, + message: "Game over! Let’s see how you did!" + ) + ) + try await environment.database.saveGame(completedGame) + } + } else { + switch move.type { + case .removedCube: + let shouldEndTurn = + state.moves.count > 1 + && state.moves[state.moves.count - 2].playerIndex + == turnBasedContext.localPlayerIndex + + if shouldEndTurn { + try await environment.gameCenter.turnBasedMatch.endTurn( + .init( + for: turnBasedContext.match.matchId, + matchData: matchData, + message: "\(turnBasedContext.localPlayer.displayName) removed cubes!" + ) + ) + } else { + try await environment.gameCenter.turnBasedMatch + .saveCurrentTurn(turnBasedContext.match.matchId, matchData) + } + + case let .playedWord(cubeFaces): + let word = state.cubes.string(from: cubeFaces) + let score = SharedModels.score(word) + let reaction = (move.reactions?.values.first).map { " \($0.rawValue)" } ?? "" + + try await environment.gameCenter.turnBasedMatch.endTurn( + .init( + for: turnBasedContext.match.matchId, + matchData: matchData, + message: """ + \(turnBasedContext.localPlayer.displayName) played \(word)! \ + (+\(score)\(reaction)) + """ + ) + ) + } + } + return try await environment.gameCenter.turnBasedMatch + .load(turnBasedContext.match.matchId) + } ) - } + ) } default: return .none @@ -1245,5 +1217,3 @@ extension CompletedGame { ) } } - -enum ListenerId {} diff --git a/Sources/GameCore/SoundsCore.swift b/Sources/GameCore/SoundsCore.swift index 60d984ef..ea392368 100644 --- a/Sources/GameCore/SoundsCore.swift +++ b/Sources/GameCore/SoundsCore.swift @@ -9,27 +9,24 @@ extension Reducer where State == GameState, Action == GameAction, Environment == .combined( with: .init { state, action, environment in switch action { - case .onAppear: - let soundEffect: Effect - if state.gameMode == .timed { - soundEffect = environment.audioPlayer - .play( - state.isDemo - ? .timedGameBgLoop1 - : [.timedGameBgLoop1, .timedGameBgLoop2].randomElement()! - ) + case .task: + return .fireAndForget { [gameMode = state.gameMode, isDemo = state.isDemo] in + if gameMode == .timed { + await environment.audioPlayer + .play( + isDemo + ? .timedGameBgLoop1 + : [.timedGameBgLoop1, .timedGameBgLoop2].randomElement()! + ) - } else { - soundEffect = environment.audioPlayer - .loop([.unlimitedGameBgLoop1, .unlimitedGameBgLoop2].randomElement()!) + } else { + await environment.audioPlayer + .loop([.unlimitedGameBgLoop1, .unlimitedGameBgLoop2].randomElement()!) + } } - return - soundEffect - .fireAndForget() case .confirmRemoveCube: - return environment.audioPlayer.play(.cubeRemove) - .fireAndForget() + return .fireAndForget { await environment.audioPlayer.play(.cubeRemove) } default: return .none @@ -37,27 +34,20 @@ extension Reducer where State == GameState, Action == GameAction, Environment == } ) .onChange(of: { $0.gameOver == nil }) { _, _, _, environment in - .merge( - Effect - .merge( - AudioPlayerClient.Sound.allMusic - .filter { $0 != .gameOverMusicLoop } - .map(environment.audioPlayer.stop) - ) - .fireAndForget(), - - .cancel(id: CubeShakingId()) - ) + .fireAndForget { + await Task.cancel(id: CubeShakingID.self) + for music in AudioPlayerClient.Sound.allMusic where music != .gameOverMusicLoop { + await environment.audioPlayer.stop(music) + } + } } .onChange(of: \.secondsPlayed) { secondsPlayed, state, _, environment in if secondsPlayed == state.gameMode.seconds - 10 { - return environment.audioPlayer.play(.timed10SecWarning) - .fireAndForget() + return .fireAndForget { await environment.audioPlayer.play(.timed10SecWarning) } } else if secondsPlayed >= state.gameMode.seconds - 5 && secondsPlayed <= state.gameMode.seconds { - return environment.audioPlayer.play(.timedCountdownTone) - .fireAndForget() + return .fireAndForget { await environment.audioPlayer.play(.timedCountdownTone) } } else { return .none } @@ -72,19 +62,17 @@ extension Reducer where State == GameState, Action == GameAction, Environment == switch action { case .submitButtonTapped, .wordSubmitButton(.delegate(.confirmSubmit)): - return environment.audioPlayer.play(.invalidWord) - .fireAndForget() + return .fireAndForget { await environment.audioPlayer.play(.invalidWord) } default: - return environment.audioPlayer.play(.cubeDeselect) - .fireAndForget() + return .fireAndForget { await environment.audioPlayer.play(.cubeDeselect) } } } .onChange(of: \.selectedWord) { previousSelection, selectedWord, state, _, environment in guard !selectedWord.isEmpty else { state.cubeStartedShakingAt = nil - return .cancel(id: CubeShakingId()) + return .cancel(id: CubeShakingID.self) } let previousWord = state.cubes.string(from: previousSelection) @@ -101,21 +89,17 @@ extension Reducer where State == GameState, Action == GameAction, Environment == if cubeIsShaking { state.cubeStartedShakingAt = state.cubeStartedShakingAt ?? environment.date() - return cubeWasShaking - ? .none - : Effect.timer( - id: CubeShakingId(), - every: .seconds(2), - on: environment.mainQueue - ) - .flatMap { _ in environment.audioPlayer.play(.cubeShake) } - .merge(with: environment.audioPlayer.play(.cubeShake)) - .eraseToEffect() - .fireAndForget() + return cubeWasShaking ? .none : .fireAndForget { + await environment.audioPlayer.play(.cubeShake) + for await _ in environment.mainQueue.timer(interval: .seconds(2)) { + await environment.audioPlayer.play(.cubeShake) + } + } + .cancellable(id: CubeShakingID.self) } else { state.cubeStartedShakingAt = nil - return .cancel(id: CubeShakingId()) + return .cancel(id: CubeShakingID.self) } } .onChange(of: \.moves.last) { lastMove, state, _, environment in @@ -132,8 +116,9 @@ extension Reducer where State == GameState, Action == GameAction, Environment == .remainder ) - return environment.audioPlayer.play(AudioPlayerClient.Sound.allSubmits[firstIndex]) - .fireAndForget() + return .fireAndForget { + await environment.audioPlayer.play(AudioPlayerClient.Sound.allSubmits[firstIndex]) + } } .selectionSounds( audioPlayer: \.audioPlayer, @@ -149,7 +134,7 @@ extension Reducer where State == GameState, Action == GameAction, Environment == } } -private struct CubeShakingId: Hashable {} +private enum CubeShakingID {} extension GameState { func hasBeenPlayed(word: String) -> Bool { diff --git a/Sources/GameCore/TurnBased.swift b/Sources/GameCore/TurnBased.swift index 8a798211..4422381d 100644 --- a/Sources/GameCore/TurnBased.swift +++ b/Sources/GameCore/TurnBased.swift @@ -26,7 +26,7 @@ extension Reducer where State == GameState, Action == GameAction, Environment == .lowPowerModeChanged, .matchesLoaded, .menuButtonTapped, - .onAppear, + .task, .savedGamesLoaded, .settingsButtonTapped, .timerTick, diff --git a/Sources/GameCore/Views/GameView.swift b/Sources/GameCore/Views/GameView.swift index 56c652f2..a4fa00c2 100644 --- a/Sources/GameCore/Views/GameView.swift +++ b/Sources/GameCore/Views/GameView.swift @@ -184,6 +184,6 @@ public struct GameView: View where Content: View { dismiss: .dismiss ) } - .onAppear { self.viewStore.send(.onAppear) } + .task { await self.viewStore.send(.task).finish() } } } diff --git a/Sources/GameCore/Views/WordSubmitButton.swift b/Sources/GameCore/Views/WordSubmitButton.swift index 16a3565e..313e79b4 100644 --- a/Sources/GameCore/Views/WordSubmitButton.swift +++ b/Sources/GameCore/Views/WordSubmitButton.swift @@ -66,7 +66,7 @@ let wordSubmitReducer = Reducer< WordSubmitButtonFeatureState, WordSubmitButtonAction, WordSubmitEnvironment > { state, action, environment in - struct SubmitButtonPressedDelayId: Hashable {} + enum SubmitButtonPressedDelayID {} guard state.isYourTurn else { return .none } @@ -74,67 +74,45 @@ let wordSubmitReducer = Reducer< switch action { case .backgroundTapped: state.wordSubmitButton.areReactionsOpen = false - return environment.audioPlayer.play(.uiSfxEmojiClose) - .fireAndForget() + return .fireAndForget { await environment.audioPlayer.play(.uiSfxEmojiClose) } case .delayedSubmitButtonPressed: state.wordSubmitButton.areReactionsOpen = true - return .merge( - environment.feedbackGenerator.selectionChanged() - .fireAndForget(), - - environment.audioPlayer.play(.uiSfxEmojiOpen) - .fireAndForget() - ) + return .fireAndForget { + await environment.feedbackGenerator.selectionChanged() + await environment.audioPlayer.play(.uiSfxEmojiOpen) + } case .delegate: return .none case let .reactionButtonTapped(reaction): state.wordSubmitButton.areReactionsOpen = false - return .merge( - environment.feedbackGenerator.selectionChanged() - .fireAndForget(), - - environment.audioPlayer.play(.uiSfxEmojiSend) - .fireAndForget(), - - Effect(value: .delegate(.confirmSubmit(reaction: reaction))) - ) + return .task { + await environment.feedbackGenerator.selectionChanged() + await environment.audioPlayer.play(.uiSfxEmojiSend) + return .delegate(.confirmSubmit(reaction: reaction)) + } case .submitButtonPressed: guard state.isTurnBasedMatch else { return .none } - let closeSound: Effect if state.wordSubmitButton.areReactionsOpen { state.wordSubmitButton.isClosing = true - closeSound = environment.audioPlayer.play(.uiSfxEmojiClose) - } else { - closeSound = .none } state.wordSubmitButton.areReactionsOpen = false state.wordSubmitButton.isSubmitButtonPressed = true - let longPressEffect: Effect - if state.isSelectedWordValid { - longPressEffect = Effect(value: .delayedSubmitButtonPressed) - .delay(for: 0.5, scheduler: environment.mainQueue) - .eraseToEffect() - .cancellable(id: SubmitButtonPressedDelayId(), cancelInFlight: true) - } else { - longPressEffect = .none + return .task { [isClosing = state.wordSubmitButton.isClosing] in + await environment.feedbackGenerator.selectionChanged() + if isClosing { + await environment.audioPlayer.play(.uiSfxEmojiClose) + } + try await environment.mainQueue.sleep(for: 0.5) + return .delayedSubmitButtonPressed } - - return .merge( - longPressEffect, - - closeSound - .fireAndForget(), - - environment.feedbackGenerator.selectionChanged() - .fireAndForget() - ) + .cancellable(id: SubmitButtonPressedDelayID.self, cancelInFlight: true) case .submitButtonReleased: guard state.isTurnBasedMatch @@ -143,19 +121,19 @@ let wordSubmitReducer = Reducer< let wasClosing = state.wordSubmitButton.isClosing state.wordSubmitButton.isClosing = false state.wordSubmitButton.isSubmitButtonPressed = false - return .merge( - .cancel(id: SubmitButtonPressedDelayId()), - wasClosing || state.wordSubmitButton.areReactionsOpen - ? .none - : Effect(value: .delegate(.confirmSubmit(reaction: nil))) - ) + return .run { [areReactionsOpen = state.wordSubmitButton.areReactionsOpen] send in + await Task.cancel(id: SubmitButtonPressedDelayID.self) + guard !wasClosing && !areReactionsOpen + else { return } + await send(.delegate(.confirmSubmit(reaction: nil))) + } case .submitButtonTapped: guard !state.isTurnBasedMatch else { return .none } - return Effect(value: .delegate(.confirmSubmit(reaction: nil))) + return .task { .delegate(.confirmSubmit(reaction: nil)) } } } @@ -236,10 +214,8 @@ public struct WordSubmitButton: View { ? Color.isowordsBlack.opacity(0.4) : nil ) - .animation(.default) - .onTapGesture { - self.viewStore.send(.backgroundTapped, animation: .default) - } + .animation(.default, value: self.viewStore.wordSubmitButton.areReactionsOpen) + .onTapGesture { self.viewStore.send(.backgroundTapped, animation: .default) } } } @@ -268,7 +244,8 @@ struct ReactionsView: View { .opacity(self.viewStore.areReactionsOpen ? 1 : 0) .offset(x: offset.x, y: offset.y) .animation( - Animation.default.delay(Double(idx) / Double(self.viewStore.favoriteReactions.count * 10)) + .default.delay(Double(idx) / Double(self.viewStore.favoriteReactions.count * 10)), + value: self.viewStore.areReactionsOpen ) } } diff --git a/Sources/GameFeature/GameFeature.swift b/Sources/GameFeature/GameFeature.swift index 8c797049..0caad779 100644 --- a/Sources/GameFeature/GameFeature.swift +++ b/Sources/GameFeature/GameFeature.swift @@ -19,9 +19,9 @@ public struct GameFeatureState: Equatable { } public enum GameFeatureAction: Equatable { + case dismissSettings case game(GameAction) case settings(SettingsAction) - case onDisappear } public let gameFeatureReducer = Reducer @@ -61,11 +61,7 @@ public let gameFeatureReducer = Reducer: View where Content: View { isAnimationReduced: viewStore.state, store: store.scope(state: { $0 }, action: GameFeatureAction.game) ) - .onDisappear { viewStore.send(.onDisappear) } } } ) @@ -39,12 +38,7 @@ public struct GameFeatureView: View where Content: View { // using an .alert/.sheet modifier, then the child view’s alert/sheet will never appear: // https://gist.github.com/mbrandonw/82ece7c62afb370a875fd1db2f9a236e EmptyView() - .sheet( - isPresented: viewStore.binding( - get: { $0 }, - send: .settings(.onDismiss) - ) - ) { + .sheet(isPresented: viewStore.binding(send: .dismissSettings)) { NavigationView { SettingsView( store: self.store.scope( @@ -124,7 +118,7 @@ public struct GameFeatureView: View where Content: View { mainRunLoop: .main, remoteNotifications: .noop, serverConfig: .noop, - setUserInterfaceStyle: { _ in .none }, + setUserInterfaceStyle: { _ in }, storeKit: .live(), userDefaults: .noop, userNotifications: .live diff --git a/Sources/GameOverFeature/GameOverView.swift b/Sources/GameOverFeature/GameOverView.swift index 3be54a1d..ba82986a 100644 --- a/Sources/GameOverFeature/GameOverView.swift +++ b/Sources/GameOverFeature/GameOverView.swift @@ -76,17 +76,17 @@ public struct GameOverState: Equatable { public enum GameOverAction: Equatable { case closeButtonTapped - case dailyChallengeResponse(Result<[FetchTodaysDailyChallengeResponse], ApiError>) + case dailyChallengeResponse(TaskResult<[FetchTodaysDailyChallengeResponse]>) case delayedOnAppear case delayedShowUpgradeInterstitial case delegate(DelegateAction) case gameButtonTapped(GameMode) - case onAppear case notificationsAuthAlert(NotificationsAuthAlertAction) case rematchButtonTapped case showConfetti - case startDailyChallengeResponse(Result) - case submitGameResponse(Result) + case startDailyChallengeResponse(TaskResult) + case task + case submitGameResponse(TaskResult) case upgradeInterstitial(UpgradeInterstitialAction) case userNotificationSettingsResponse(UserNotificationClient.Notification.Settings) @@ -134,19 +134,39 @@ public struct GameOverEnvironment { } #if DEBUG - public static let failing = Self( - apiClient: .failing, - audioPlayer: .failing, - database: .failing, - fileClient: .failing, - mainRunLoop: .failing, - remoteNotifications: .failing, - serverConfig: .failing, - storeKit: .failing, - userDefaults: .failing, - userNotifications: .failing + public static let unimplemented = Self( + apiClient: .unimplemented, + audioPlayer: .unimplemented, + database: .unimplemented, + fileClient: .unimplemented, + mainRunLoop: .unimplemented, + remoteNotifications: .unimplemented, + serverConfig: .unimplemented, + storeKit: .unimplemented, + userDefaults: .unimplemented, + userNotifications: .unimplemented ) #endif + + func requestReviewAsync() async throws { + let stats = try await self.database.fetchStats() + let hasRequestedReviewBefore = + self.userDefaults.doubleForKey(lastReviewRequestTimeIntervalKey) != 0 + let timeSinceLastReviewRequest = + self.mainRunLoop.now.date.timeIntervalSince1970 + - self.userDefaults.doubleForKey(lastReviewRequestTimeIntervalKey) + let weekInSeconds: Double = 60 * 60 * 24 * 7 + + if stats.gamesPlayed >= 3 + && (!hasRequestedReviewBefore || timeSinceLastReviewRequest >= weekInSeconds) + { + await self.storeKit.requestReview() + await self.userDefaults.setDouble( + self.mainRunLoop.now.date.timeIntervalSince1970, + lastReviewRequestTimeIntervalKey + ) + } + } } public let gameOverReducer = Reducer.combine( @@ -179,13 +199,6 @@ public let gameOverReducer = Reducer { - // Effect(value: .showConfetti) - // .delay(for: 1, scheduler: environment.mainQueue) - // .eraseToEffect() - // } - switch action { case .closeButtonTapped: guard @@ -193,10 +206,10 @@ public let gameOverReducer = Reducer 0 - else { - return Effect(value: .delegate(.close)) - .receive(on: ImmediateScheduler.shared.animation(.default)) - .eraseToEffect() - } - - let submitGameEffect: Effect - if state.isDemo { - submitGameEffect = environment.apiClient.request( - route: .demo( - .submitGame( - .init( - gameMode: state.completedGame.gameMode, - score: state.completedGame.currentScore - ) - ) - ), - as: LeaderboardScoreResult.self - ) - .receive(on: environment.mainRunLoop.animation(.default)) - .map(SubmitGameResponse.solo) - .catchToEffect(GameOverAction.submitGameResponse) - } else if let request = ServerRoute.Api.Route.Games.SubmitRequest( - completedGame: state.completedGame) - { - submitGameEffect = environment.apiClient.apiRequest( - route: .games(.submit(request)), - as: SubmitGameResponse.self - ) - .receive(on: environment.mainRunLoop.animation(.default)) - .catchToEffect(GameOverAction.submitGameResponse) - } else { - submitGameEffect = .none - } - - // let turnBasedConfettiEffect = state.turnBasedContext?.localParticipant?.matchOutcome == .won - // ? showConfetti - // : .none - - return .merge( - Effect(value: .delayedOnAppear) - .delay(for: 2, scheduler: environment.mainRunLoop) - .eraseToEffect(), - - submitGameEffect, - - // turnBasedConfettiEffect, - - Effect.showUpgradeInterstitial( - gameContext: .init(gameContext: state.completedGame.gameContext), - isFullGamePurchased: environment.apiClient.currentPlayer()?.appleReceipt != nil, - serverConfig: environment.serverConfig.config(), - playedGamesCount: { - environment.database.playedGamesCount( - .init(gameContext: state.completedGame.gameContext) - ) - } - ) - .flatMap { showUpgrade in - showUpgrade - ? Effect(value: GameOverAction.delayedShowUpgradeInterstitial) - .delay(for: 1, scheduler: environment.mainRunLoop.animation(.easeIn)) - .eraseToEffect() - : Effect.none - } - .ignoreFailure() - .eraseToEffect(), - - environment.userNotifications.getNotificationSettings - .receive(on: environment.mainRunLoop) - .map(GameOverAction.userNotificationSettingsResponse) - .eraseToEffect(), - - environment.audioPlayer.loop(.gameOverMusicLoop) - .fireAndForget(), - - environment.audioPlayer.play(.transitionIn) - .fireAndForget() - ) - case .notificationsAuthAlert(.delegate(.close)): state.notificationsAuthAlert = nil - return .merge( - Effect(value: .delegate(.close)) - .receive(on: ImmediateScheduler.shared.animation()) - .eraseToEffect(), - .reviewRequestEffect(environment: environment) - ) + return .run { send in + try? await environment.requestReviewAsync() + await send(.delegate(.close), animation: .default) + } case .notificationsAuthAlert(.delegate(.didChooseNotificationSettings)): - return Effect(value: .delegate(.close)) - .receive(on: ImmediateScheduler.shared.animation()) - .eraseToEffect() + return .task { .delegate(.close) }.animation() case .notificationsAuthAlert: return .none @@ -354,9 +283,6 @@ public let gameOverReducer = Reducer 0 + else { + await send(.delegate(.close), animation: .default) + return + } + + await environment.audioPlayer.play(.transitionIn) + await environment.audioPlayer.loop(.gameOverMusicLoop) + + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + if isDemo { + let request = ServerRoute.Demo.SubmitRequest( + gameMode: completedGame.gameMode, + score: completedGame.currentScore + ) + await send( + .submitGameResponse( + TaskResult { + try await .solo( + environment.apiClient.request( + route: .demo(.submitGame(request)), + as: LeaderboardScoreResult.self + ) + ) + } + ), + animation: .default + ) + } else if let request = ServerRoute.Api.Route.Games.SubmitRequest( + completedGame: completedGame + ) { + await send( + .submitGameResponse( + TaskResult { + try await environment.apiClient.apiRequest( + route: .games(.submit(request)), + as: SubmitGameResponse.self + ) + } + ), + animation: .default + ) + } + } + + group.addTask { + try await environment.mainRunLoop.sleep(for: .seconds(1)) + let playedGamesCount = try await environment.database + .playedGamesCount(.init(gameContext: completedGame.gameContext)) + let isFullGamePurchased = + environment.apiClient.currentPlayer()?.appleReceipt != nil + guard + !isFullGamePurchased, + shouldShowInterstitial( + gamePlayedCount: playedGamesCount, + gameContext: .init(gameContext: completedGame.gameContext), + serverConfig: environment.serverConfig.config() + ) + else { return } + await send(.delayedShowUpgradeInterstitial, animation: .easeIn) + } + + group.addTask { + try await environment.mainRunLoop.sleep(for: .seconds(2)) + await send(.delayedOnAppear) + } + + group.addTask { + await send( + .userNotificationSettingsResponse( + environment.userNotifications.getNotificationSettings() + ) + ) + } + } + } + case .upgradeInterstitial(.delegate(.close)), .upgradeInterstitial(.delegate(.fullGamePurchased)): state.upgradeInterstitial = nil @@ -543,10 +545,9 @@ public struct GameOverView: View { foregroundColor: self.colorScheme == .dark ? .isowordsBlack : self.color ) ) - .padding([.bottom], .grid(self.viewStore.isDemo ? 30 : 0)) + .padding(.bottom, .grid(self.viewStore.isDemo ? 30 : 0)) } - .padding([.vertical], .grid(12)) - + .padding(.vertical, .grid(12)) } IfLetStore( @@ -565,7 +566,7 @@ public struct GameOverView: View { (self.colorScheme == .dark ? .isowordsBlack : self.color) .ignoresSafeArea() ) - .onAppear { self.viewStore.send(.onAppear) } + .task { await self.viewStore.send(.task).finish() } .notificationsAlert( store: self.store.scope( state: \.notificationsAuthAlert, @@ -593,9 +594,9 @@ public struct GameOverView: View { } ?? Text("Loading your rank!") } - .animation(nil) + .animation(.default, value: result) .adaptiveFont(.matter, size: 52) - .adaptivePadding([.leading, .trailing]) + .adaptivePadding(.horizontal) .minimumScaleFactor(0.01) .lineLimit(2) .multilineTextAlignment(.center) @@ -636,8 +637,8 @@ public struct GameOverView: View { Text("\(self.viewStore.yourWords.count)") } } - .adaptivePadding([.leading, .trailing]) - .animation(nil) + .adaptivePadding(.horizontal) + .animation(.default, value: result) self.wordList @@ -672,7 +673,7 @@ public struct GameOverView: View { .disabled(self.viewStore.gameModeIsLoading != nil) } } - .adaptivePadding([.leading, .trailing]) + .adaptivePadding(.horizontal) } } .adaptiveFont(.matterMedium, size: 16) @@ -686,7 +687,7 @@ public struct GameOverView: View { + Text(praise(mode: self.viewStore.gameMode, score: self.viewStore.yourScore)) } .adaptiveFont(.matter, size: 52) - .adaptivePadding([.leading, .trailing]) + .adaptivePadding(.horizontal) .minimumScaleFactor(0.01) .lineLimit(2) .multilineTextAlignment(.center) @@ -707,17 +708,20 @@ public struct GameOverView: View { let rank = (/GameOverState.RankSummary.leaderboard) .extract(from: self.viewStore.summary)?[timeScope] Text( - "\((rank?.rank ?? 0) as NSNumber, formatter: ordinalFormatter) of \(rank?.outOf ?? 0)" + """ + \((rank?.rank ?? 0) as NSNumber, formatter: ordinalFormatter) of \ + \(rank?.outOf ?? 0) + """ ) .redacted(reason: rank == nil ? .placeholder : []) } } } .frame(maxWidth: .infinity, alignment: .leading) - .animation(nil) + .animation(.default, value: self.viewStore.summary) } .adaptiveFont(.matterMedium, size: 16) - .adaptivePadding([.leading, .trailing]) + .adaptivePadding(.horizontal) .overlay( self.viewStore.showConfetti ? Confetti( @@ -733,7 +737,7 @@ public struct GameOverView: View { VStack(spacing: self.adaptiveSize.pad(8)) { Text("Play again") .adaptiveFont(.matterMedium, size: 16) - .adaptivePadding([.leading, .trailing]) + .adaptivePadding(.horizontal) .frame(maxWidth: .infinity, alignment: .leading) LazyVGrid( @@ -762,13 +766,13 @@ public struct GameOverView: View { action: { self.viewStore.send(.gameButtonTapped(.unlimited), animation: .default) } ) } - .adaptivePadding([.leading, .trailing]) + .adaptivePadding(.horizontal) } } } } - struct DividerId: Hashable {} + struct DividerID: Hashable {} @State var containerWidth: CGFloat = 0 @State var dividerOffset: CGFloat = 0 @@ -830,7 +834,7 @@ public struct GameOverView: View { .clipShape(Circle()) } .frame(maxWidth: .infinity) - .padding([.leading, .trailing], .grid(2)) + .padding(.horizontal, .grid(2)) Divider() .frame(height: 2) @@ -848,13 +852,13 @@ public struct GameOverView: View { .padding(.top, self.viewStore.words.first?.isYourWord == .some(true) ? 0 : .grid(6)) .padding(.grid(2)) } - .padding([.top, .bottom]) + .padding(.vertical) .frame(maxWidth: .infinity) Divider() .frame(width: 2) .background((self.colorScheme == .dark ? self.color : .isowordsBlack).opacity(0.2)) - .id(DividerId()) + .id(DividerID()) VStack(alignment: .leading) { HStack { @@ -880,7 +884,7 @@ public struct GameOverView: View { } } .frame(maxWidth: .infinity) - .padding([.leading, .trailing], .grid(2)) + .padding(.horizontal, .grid(2)) Divider() .frame(height: 2) @@ -905,14 +909,14 @@ public struct GameOverView: View { .padding(.top, self.viewStore.words.first?.isYourWord == .some(true) ? .grid(6) : 0) .padding(.grid(2)) } - .padding([.top, .bottom]) + .padding(.vertical) .frame(maxWidth: .infinity) } .fixedSize() - .adaptivePadding([.leading, .trailing]) + .adaptivePadding(.horizontal) .frame(width: UIScreen.main.bounds.size.width) .offset(x: (containerWidth / 2) - self.dividerOffset + (self.dragOffset / 2)) - .padding([.top, .bottom]) + .padding(.vertical) .gesture( DragGesture() .onChanged { self.dragOffset = $0.translation.width } @@ -951,7 +955,7 @@ public struct GameOverView: View { VStack(spacing: self.adaptiveSize.pad(12)) { Text("Your words") .adaptiveFont(.matterMedium, size: 16) - .adaptivePadding([.leading, .trailing]) + .adaptivePadding(.horizontal) .frame(maxWidth: .infinity, alignment: .leading) ScrollView(.horizontal, showsIndicators: false) { @@ -964,7 +968,7 @@ public struct GameOverView: View { ) } } - .adaptivePadding([.leading, .trailing]) + .adaptivePadding(.horizontal) } } } @@ -1003,7 +1007,7 @@ private struct WordView: View { .offset(x: 8, y: -8) } .frame(maxWidth: .infinity, alignment: self.word.isYourWord ? .trailing : .leading) - .padding([.leading, .trailing], .grid(1)) + .padding(.horizontal, .grid(1)) .fixedSize() } @@ -1040,35 +1044,6 @@ extension CompletedMatch { } } -extension Effect where Output == GameOverAction, Failure == Never { - static func reviewRequestEffect(environment: GameOverEnvironment) -> Self { - let hasRequestedReviewBefore = - environment.userDefaults - .doubleForKey(lastReviewRequestTimeIntervalKey) != 0 - let timeSinceLastReviewRequest = - environment.mainRunLoop.now.date.timeIntervalSince1970 - - environment.userDefaults.doubleForKey(lastReviewRequestTimeIntervalKey) - let weekInSeconds: Double = 60 * 60 * 24 * 7 - - return environment.database.fetchStats - .ignoreFailure() - .flatMap { stats in - stats.gamesPlayed >= 3 - && (!hasRequestedReviewBefore || timeSinceLastReviewRequest >= weekInSeconds) - ? Effect.merge( - environment.userDefaults.setDouble( - environment.mainRunLoop.now.date.timeIntervalSince1970, - lastReviewRequestTimeIntervalKey - ) - .fireAndForget(), - environment.storeKit.requestReview().fireAndForget() - ) - : Effect.none - } - .eraseToEffect() - } -} - extension UpgradeInterstitialFeature.GameContext { init(gameContext: CompletedGame.GameContext) { switch gameContext { diff --git a/Sources/HapticsCore/HapticsCore.swift b/Sources/HapticsCore/HapticsCore.swift index dc89f252..53206e7c 100644 --- a/Sources/HapticsCore/HapticsCore.swift +++ b/Sources/HapticsCore/HapticsCore.swift @@ -12,7 +12,8 @@ extension Reducer { of: trigger, perform: { _, state, _, environment in guard isEnabled(state) else { return .none } - return feedbackGenerator(environment).selectionChanged().fireAndForget() - }) + return .fireAndForget { await feedbackGenerator(environment).selectionChanged() } + } + ) } } diff --git a/Sources/HomeFeature/Home.swift b/Sources/HomeFeature/Home.swift index 8546c189..79b47793 100644 --- a/Sources/HomeFeature/Home.swift +++ b/Sources/HomeFeature/Home.swift @@ -8,7 +8,6 @@ import Combine import CombineHelpers import ComposableArchitecture import ComposableGameCenter -import ComposableGameCenterHelpers import ComposableStoreKit import ComposableUserNotifications import DailyChallengeFeature @@ -68,7 +67,7 @@ public struct HomeState: Equatable { public var changelog: ChangelogState? public var dailyChallenges: [FetchTodaysDailyChallengeResponse]? public var hasChangelog: Bool - @BindableState public var hasPastTurnBasedGames: Bool + public var hasPastTurnBasedGames: Bool public var nagBanner: NagBannerState? public var route: HomeRoute? public var savedGames: SavedGamesState { @@ -118,30 +117,34 @@ public struct HomeState: Equatable { self.turnBasedMatches = turnBasedMatches self.weekInReview = weekInReview } + + var hasActiveGames: Bool { + self.savedGames.dailyChallengeUnlimited != nil + || self.savedGames.unlimited != nil + || !self.turnBasedMatches.isEmpty + } } -public enum HomeAction: BindableAction, Equatable { +public enum HomeAction: Equatable { + case activeMatchesResponse(TaskResult) case activeGames(ActiveGamesAction) case authenticationResponse(CurrentPlayerEnvelope) - case binding(BindingAction) case changelog(ChangelogAction) case cubeButtonTapped case dailyChallenge(DailyChallengeAction) - case dailyChallengeResponse(Result<[FetchTodaysDailyChallengeResponse], ApiError>) + case dailyChallengeResponse(TaskResult<[FetchTodaysDailyChallengeResponse]>) case dismissChangelog case gameButtonTapped(GameButtonAction) case howToPlayButtonTapped case leaderboard(LeaderboardAction) - case matchesLoaded(Result<[ActiveTurnBasedMatch], NSError>) case multiplayer(MultiplayerAction) case nagBannerFeature(NagBannerFeatureAction) - case onAppear - case onDisappear case serverConfigResponse(ServerConfig) case setNavigation(tag: HomeRoute.Tag?) case settings(SettingsAction) case solo(SoloAction) - case weekInReviewResponse(Result) + case task + case weekInReviewResponse(TaskResult) public enum GameButtonAction: Equatable { case dailyChallenge @@ -150,6 +153,11 @@ public enum HomeAction: BindableAction, Equatable { } } +public struct ActiveMatchResponse: Equatable { + public let matches: [ActiveTurnBasedMatch] + public let hasPastTurnBasedGames: Bool +} + public struct HomeEnvironment { public var apiClient: ApiClient public var applicationClient: UIApplicationClient @@ -166,7 +174,7 @@ public struct HomeEnvironment { public var mainRunLoop: AnySchedulerOf public var remoteNotifications: RemoteNotificationsClient public var serverConfig: ServerConfigClient - public var setUserInterfaceStyle: (UIUserInterfaceStyle) -> Effect + public var setUserInterfaceStyle: @Sendable (UIUserInterfaceStyle) async -> Void public var storeKit: StoreKitClient public var timeZone: () -> TimeZone public var userDefaults: UserDefaultsClient @@ -188,7 +196,7 @@ public struct HomeEnvironment { mainRunLoop: AnySchedulerOf, remoteNotifications: RemoteNotificationsClient, serverConfig: ServerConfigClient, - setUserInterfaceStyle: @escaping (UIUserInterfaceStyle) -> Effect, + setUserInterfaceStyle: @escaping @Sendable (UIUserInterfaceStyle) async -> Void, storeKit: StoreKitClient, timeZone: @escaping () -> TimeZone, userDefaults: UserDefaultsClient, @@ -235,7 +243,7 @@ public struct HomeEnvironment { mainRunLoop: .immediate, remoteNotifications: .noop, serverConfig: .noop, - setUserInterfaceStyle: { _ in .none }, + setUserInterfaceStyle: { _ in }, storeKit: .noop, timeZone: { TimeZone(secondsFromGMT: 0)! }, userDefaults: .noop, @@ -254,7 +262,6 @@ public let homeReducer = Reducer.combine apiClient: $0.apiClient, applicationClient: $0.applicationClient, build: $0.build, - mainQueue: $0.mainQueue, serverConfig: $0.serverConfig, userDefaults: $0.userDefaults ) @@ -353,30 +360,59 @@ public let homeReducer = Reducer.combine .init { state, action, environment in switch action { + case let .activeMatchesResponse(.success(response)): + state.hasPastTurnBasedGames = response.hasPastTurnBasedGames + state.turnBasedMatches = response.matches + return .none + + case .activeMatchesResponse(.failure): + return .none + case let .activeGames(.turnBasedGameMenuItemTapped(.deleteMatch(matchId))): - return .concatenate( - environment.gameCenter.turnBasedMatch.load(matchId) - .flatMap { match in - forceQuitMatch(match: match, gameCenter: environment.gameCenter) + return .run { send in + let localPlayer = environment.gameCenter.localPlayer.localPlayer() + + do { + let match = try await environment.gameCenter.turnBasedMatch.load(matchId) + let currentParticipantIsLocalPlayer = + match.currentParticipant?.player?.gamePlayerId == localPlayer.gamePlayerId + + if currentParticipantIsLocalPlayer { + try await environment.gameCenter.turnBasedMatch + .endMatchInTurn( + .init( + for: match.matchId, + matchData: match.matchData ?? Data(), + localPlayerId: localPlayer.gamePlayerId, + localPlayerMatchOutcome: .quit, + message: "\(localPlayer.displayName) forfeited the match." + ) + ) + } else { + try await environment.gameCenter.turnBasedMatch + .participantQuitOutOfTurn(match.matchId) } - .fireAndForget(), + } catch {} - loadMatches( - gameCenter: environment.gameCenter, - backgroundQueue: environment.backgroundQueue, - mainRunLoop: environment.mainRunLoop - ), + await send( + .activeMatchesResponse( + TaskResult { + try await environment.gameCenter + .loadActiveMatches(now: environment.mainRunLoop.now.date) + } + ), + animation: .default + ) - environment.audioPlayer.play(.uiSfxActionDestructive) - .fireAndForget() - ) + await environment.audioPlayer.play(.uiSfxActionDestructive) + } case let .activeGames(.turnBasedGameMenuItemTapped(.rematch(matchId))): return .none case let .activeGames(.turnBasedGameMenuItemTapped(.sendReminder(matchId, otherPlayerIndex))): - return environment.gameCenter.turnBasedMatch - .sendReminder( + return .fireAndForget { + try await environment.gameCenter.turnBasedMatch.sendReminder( .init( for: matchId, to: [otherPlayerIndex.rawValue], @@ -384,7 +420,7 @@ public let homeReducer = Reducer.combine arguments: [] ) ) - .fireAndForget() + } case .activeGames: return .none @@ -409,9 +445,6 @@ public let homeReducer = Reducer.combine return .none - case .binding: - return .none - case .changelog: return .none @@ -443,44 +476,9 @@ public let homeReducer = Reducer.combine case .leaderboard: return .none - case .matchesLoaded(.failure): - return .none - - case let .matchesLoaded(.success(matches)): - state.turnBasedMatches = matches - return .none - case .multiplayer: return .none - case .onDisappear: - return .cancel(id: ListenerId()) - - case .onAppear: - return .merge( - onAppearEffects(environment: environment), - - environment.gameCenter.localPlayer.listener - .cancellable(id: ListenerId(), cancelInFlight: true) - .filter { - switch $0 { - case .turnBased(.matchEnded), - .turnBased(.receivedTurnEventForMatch): - return true - default: - return false - } - } - .flatMap { _ in - loadMatches( - gameCenter: environment.gameCenter, - backgroundQueue: environment.backgroundQueue, - mainRunLoop: environment.mainRunLoop - ) - } - .eraseToEffect() - ) - case let .serverConfigResponse(serverConfig): state.hasChangelog = serverConfig.newestBuild > environment.build.number() return .none @@ -526,6 +524,13 @@ public let homeReducer = Reducer.combine case .solo: return .none + case .task: + return .run { send in + async let authenticate: Void = authenticate(send: send, environment: environment) + await listenForGameCenterEvents(send: send, environment: environment) + } + .animation() + case .weekInReviewResponse(.failure): return .none @@ -535,7 +540,95 @@ public let homeReducer = Reducer.combine } } ) -.binding() + +private func authenticate(send: Send, environment: HomeEnvironment) async { + do { + try? await environment.gameCenter.localPlayer.authenticate() + + let localPlayer = environment.gameCenter.localPlayer.localPlayer() + let currentPlayerEnvelope = try await environment.apiClient.authenticate( + .init( + deviceId: .init(rawValue: environment.deviceId.id()), + displayName: localPlayer.isAuthenticated ? localPlayer.displayName : nil, + gameCenterLocalPlayerId: localPlayer.isAuthenticated + ? .init(rawValue: localPlayer.gamePlayerId.rawValue) + : nil, + timeZone: environment.timeZone().identifier + ) + ) + await send(.authenticationResponse(dump(currentPlayerEnvelope))) + + async let serverConfigResponse: Void = send( + .serverConfigResponse(environment.serverConfig.refresh()) + ) + + async let dailyChallengeResponse: Void = send( + .dailyChallengeResponse( + TaskResult { + try await environment.apiClient.apiRequest( + route: .dailyChallenge(.today(language: .en)), + as: [FetchTodaysDailyChallengeResponse].self + ) + } + ) + ) + async let weekInReviewResponse: Void = send( + .weekInReviewResponse( + TaskResult { + try await environment.apiClient.apiRequest( + route: .leaderboard(.weekInReview(language: .en)), + as: FetchWeekInReviewResponse.self + ) + } + ) + ) + async let activeMatchesResponse: Void = send( + .activeMatchesResponse( + TaskResult { + try await environment.gameCenter + .loadActiveMatches(now: environment.mainRunLoop.now.date) + } + ) + ) + _ = try await ( + serverConfigResponse, + dailyChallengeResponse, + weekInReviewResponse, + activeMatchesResponse + ) + } catch {} +} + +private func listenForGameCenterEvents(send: Send, environment: HomeEnvironment) async { + for await event in environment.gameCenter.localPlayer.listener() { + switch event { + case .turnBased(.matchEnded), + .turnBased(.receivedTurnEventForMatch): + await send( + .activeMatchesResponse( + TaskResult { + try await environment.gameCenter + .loadActiveMatches(now: environment.mainRunLoop.now.date) + } + ) + ) + default: + break + } + } +} + +extension GameCenterClient { + fileprivate func loadActiveMatches( + now: Date + ) async throws -> ActiveMatchResponse { + let localPlayer = self.localPlayer.localPlayer() + let matches = try await self.turnBasedMatch.loadMatches() + let activeMatches = matches.activeMatches(for: localPlayer, at: now) + let hasPastTurnBasedGames = matches.contains { $0.status == .ended } + return ActiveMatchResponse(matches: activeMatches, hasPastTurnBasedGames: hasPastTurnBasedGames) + } +} public struct HomeView: View { struct ViewState: Equatable { @@ -599,7 +692,7 @@ public struct HomeView: View { } .font(.system(size: 24)) .foregroundColor(self.colorScheme == .dark ? .hex(0xF2E29F) : .isowordsBlack) - .adaptivePadding([.leading, .trailing]) + .adaptivePadding(.horizontal) DailyChallengeHeaderView(store: self.store) .screenEdgePadding(.horizontal) @@ -628,7 +721,7 @@ public struct HomeView: View { LeaderboardLinkView(store: self.store) .screenEdgePadding(.horizontal) } - .adaptivePadding([.top, .bottom], .grid(4)) + .adaptivePadding(.vertical, .grid(4)) .background( self.colorScheme == .dark ? AnyView(Color.isowordsBlack) @@ -693,125 +786,10 @@ public struct HomeView: View { ) } ) - .onAppear { self.viewStore.send(.onAppear) } - .onDisappear { self.viewStore.send(.onDisappear) } - } -} - -extension HomeState { - var hasActiveGames: Bool { - self.savedGames.dailyChallengeUnlimited != nil - || self.savedGames.unlimited != nil - || !self.turnBasedMatches.isEmpty + .task { await self.viewStore.send(.task).finish() } } } -func onAppearEffects(environment: HomeEnvironment) -> Effect { - var serverAuthentication: Effect { - environment.apiClient.authenticate( - .init( - deviceId: .init(rawValue: environment.deviceId.id()), - displayName: environment.gameCenter.localPlayer.localPlayer().isAuthenticated - ? environment.gameCenter.localPlayer.localPlayer().displayName - : nil, - gameCenterLocalPlayerId: environment.gameCenter.localPlayer.localPlayer().isAuthenticated - ? .init(rawValue: environment.gameCenter.localPlayer.localPlayer().gamePlayerId.rawValue) - : nil, - timeZone: environment.timeZone().identifier - ) - ) - .ignoreFailure() - .flatMap { envelope in - Just(HomeAction.authenticationResponse(envelope)) - .merge( - with: environment.serverConfig.refresh() - .ignoreFailure() - .receive(on: environment.mainQueue) - .map(HomeAction.serverConfigResponse) - ) - } - .eraseToEffect() - } - - let serverAuthenticateAndLoadData = serverAuthentication.flatMap { authentication in - Effect.merge( - Effect(value: authentication), - - environment.apiClient - .apiRequest( - route: .dailyChallenge(.today(language: .en)), - as: [FetchTodaysDailyChallengeResponse].self - ) - .catchToEffect(HomeAction.dailyChallengeResponse), - - environment.apiClient - .apiRequest( - route: .leaderboard(.weekInReview(language: .en)), - as: FetchWeekInReviewResponse.self - ) - .catchToEffect(HomeAction.weekInReviewResponse) - ) - } - - return - environment.gameCenter.localPlayer.authenticate - .flatMap { _ in - Publishers.Merge( - serverAuthenticateAndLoadData, - - loadMatches( - gameCenter: environment.gameCenter, - backgroundQueue: environment.backgroundQueue, - mainRunLoop: environment.mainRunLoop - ) - ) - } - .receive(on: environment.mainQueue.animation()) - .eraseToEffect() - .cancellable(id: AuthenticationId(), cancelInFlight: true) -} - -private func loadMatches( - gameCenter: GameCenterClient, - backgroundQueue: AnySchedulerOf, - mainRunLoop: AnySchedulerOf -) -> Effect { - - return gameCenter.turnBasedMatch.loadMatches() - .receive(on: backgroundQueue) - .mapError { $0 as NSError } - .catchToEffect() - .flatMap { result in - Effect.merge( - Effect( - value: .set( - \.$hasPastTurnBasedGames, - (try? result.get())?.contains { $0.status == .ended } == .some(true) - ) - ) - .receive(on: mainRunLoop) - .eraseToEffect(), - - Effect( - value: .matchesLoaded( - result.map { - $0.activeMatches( - for: gameCenter.localPlayer.localPlayer(), - at: mainRunLoop.now.date - ) - } - ) - ) - .receive(on: mainRunLoop.animation()) - .eraseToEffect() - ) - } - .eraseToEffect() -} - -private struct ListenerId: Hashable {} -private struct AuthenticationId: Hashable {} - private struct CubeIconView: View { let action: () -> Void let shake: Bool @@ -829,7 +807,7 @@ private struct CubeIconView: View { Image(systemName: "cube.fill") .font(.system(size: 24)) .modifier(ShakeEffect(animatableData: CGFloat(self.shake ? 1 : 0))) - .animation(.easeInOut(duration: 1)) + .animation(.easeInOut(duration: 1), value: self.shake) } } } @@ -950,7 +928,7 @@ private struct ShakeEffect: GeometryEffect { mainRunLoop: .main, remoteNotifications: .noop, serverConfig: .noop, - setUserInterfaceStyle: { _ in .none }, + setUserInterfaceStyle: { _ in }, storeKit: .noop, timeZone: { .autoupdatingCurrent }, userDefaults: .live(), diff --git a/Sources/IntegrationTestHelpers/IntegrationTestHelpers.swift b/Sources/IntegrationTestHelpers/IntegrationTestHelpers.swift index 44d1ac8f..49988a64 100644 --- a/Sources/IntegrationTestHelpers/IntegrationTestHelpers.swift +++ b/Sources/IntegrationTestHelpers/IntegrationTestHelpers.swift @@ -7,6 +7,7 @@ import HttpPipeline import Prelude import ServerRouter import SharedModels +import TcaHelpers import TestHelpers extension ApiClient { @@ -14,21 +15,34 @@ extension ApiClient { middleware: @escaping Middleware, router: ServerRouter ) { + // TODO: Fix sync interfaces or migrate fully to async var currentPlayer: CurrentPlayerEnvelope? - var baseUrl = URL(string: "/")! - self.init( - apiRequest: { route in + actor Session { + nonisolated let baseUrl: Isolated + nonisolated let currentPlayer = Isolated(nil) + private let middleware: Middleware + private let router: ServerRouter + + init( + baseUrl: URL, + middleware: @escaping Middleware, + router: ServerRouter + ) { + self.baseUrl = Isolated(baseUrl) + self.middleware = middleware + self.router = router + } + + func apiRequest(route: ServerRoute.Api.Route) async throws -> (Data, URLResponse) { guard let request = try? router.request( for: .api(.init(accessToken: .init(rawValue: .deadbeef), isDebug: true, route: route)) ), let url = request.url - else { - return Fail(error: URLError.init(.badURL)) - .eraseToEffect() - } + else { throw URLError.init(.badURL) } + let conn = middleware(connection(from: request)).perform() let response = HTTPURLResponse( @@ -38,41 +52,44 @@ extension ApiClient { headerFields: Dictionary( uniqueKeysWithValues: conn.response.headers.map { ($0.name, $0.value) }) )! - return Just((conn.data, response)) - .setFailureType(to: URLError.self) - .eraseToEffect() - }, - authenticate: { authRequest in - guard let request = try? router.request(for: .authenticate(authRequest)) - else { - return Fail(error: ApiError(error: URLError(.badURL))) - .eraseToEffect() - } + return (conn.data, response) + } + + func authenticate(request: ServerRoute.AuthenticateRequest) async throws + -> CurrentPlayerEnvelope + { + do { + guard let request = try? self.router.request(for: .authenticate(request)) + else { throw URLError(.badURL) } - return Effect.catching { - try JSONDecoder().decode( + let envelope = try JSONDecoder().decode( CurrentPlayerEnvelope.self, from: middleware(connection(from: request)).perform().data ) + // Why aren't we assigning the envelope here? + self.currentPlayer.value = .init(appleReceipt: nil, player: .blob) + return envelope + } catch { + throw ApiError(error: error) } - .mapError { ApiError(error: $0) } - .handleEvents( - receiveOutput: { _ in currentPlayer = .init(appleReceipt: nil, player: .blob) } - ) - .eraseToEffect() - }, - baseUrl: { baseUrl }, - currentPlayer: { currentPlayer }, - logout: { .fireAndForget { currentPlayer = nil } }, - refreshCurrentPlayer: { currentPlayer.map(Effect.init(value:)) ?? .none }, - request: { route in + } + + func logout() { + self.currentPlayer.value = nil + } + + func refreshCurrentPlayer() async throws -> CurrentPlayerEnvelope { + guard let currentPlayer = self.currentPlayer.value + else { throw URLError(.unknown) } + return currentPlayer + } + + func request(route: ServerRoute) async throws -> (Data, URLResponse) { guard - let request = try? router.request(for: route), + let request = try? self.router.request(for: route), let url = request.url - else { - return Fail(error: URLError.init(.badURL)) - .eraseToEffect() - } + else { throw URLError.init(.badURL) } + let conn = middleware(connection(from: request)).perform() let response = HTTPURLResponse( @@ -82,15 +99,29 @@ extension ApiClient { headerFields: Dictionary( uniqueKeysWithValues: conn.response.headers.map { ($0.name, $0.value) }) )! - return Just((conn.data, response)) - .setFailureType(to: URLError.self) - .eraseToEffect() - }, - setBaseUrl: { url in - .fireAndForget { - baseUrl = url - } + return (conn.data, response) } + + func setBaseUrl(_ url: URL) { + self.baseUrl.value = url + } + + fileprivate func setCurrentPlayer(_ player: CurrentPlayerEnvelope) { + self.currentPlayer.value = player + } + } + + let session = Session(baseUrl: baseUrl, middleware: middleware, router: router) + + self.init( + apiRequest: { try await session.apiRequest(route: $0) }, + authenticate: { try await session.authenticate(request: $0) }, + baseUrl: { session.baseUrl.value }, + currentPlayer: { session.currentPlayer.value }, + logout: { await session.logout() }, + refreshCurrentPlayer: { try await session.refreshCurrentPlayer() }, + request: { try await session.request(route: $0) }, + setBaseUrl: { await session.setBaseUrl($0) } ) } } diff --git a/Sources/LeaderboardFeature/Leaderboard.swift b/Sources/LeaderboardFeature/Leaderboard.swift index d086e451..2dbfb15e 100644 --- a/Sources/LeaderboardFeature/Leaderboard.swift +++ b/Sources/LeaderboardFeature/Leaderboard.swift @@ -66,7 +66,7 @@ public struct LeaderboardState: Equatable { public enum LeaderboardAction: Equatable { case cubePreview(CubePreviewAction) case dismissCubePreview - case fetchWordResponse(Result) + case fetchWordResponse(TaskResult) case scopeTapped(LeaderboardScope) case solo(LeaderboardResultsAction) case vocab(LeaderboardResultsAction) @@ -96,12 +96,12 @@ public struct LeaderboardEnvironment { #if DEBUG extension LeaderboardEnvironment { - public static let failing = Self( - apiClient: .failing, - audioPlayer: .failing, - feedbackGenerator: .failing, - lowPowerMode: .failing, - mainQueue: .failing("mainQueue") + public static let unimplemented = Self( + apiClient: .unimplemented, + audioPlayer: .unimplemented, + feedbackGenerator: .unimplemented, + lowPowerMode: .unimplemented, + mainQueue: .unimplemented("mainQueue") ) } #endif @@ -130,8 +130,7 @@ public let leaderboardReducer = Reducer< action: /LeaderboardAction.solo, environment: { LeaderboardResultsEnvironment( - loadResults: $0.apiClient.loadSoloResults(gameMode:timeScope:), - mainQueue: $0.mainQueue + loadResults: $0.apiClient.loadSoloResults(gameMode:timeScope:) ) } ), @@ -142,8 +141,7 @@ public let leaderboardReducer = Reducer< action: /LeaderboardAction.vocab, environment: { LeaderboardResultsEnvironment( - loadResults: $0.apiClient.loadVocabResults(gameMode:timeScope:), - mainQueue: $0.mainQueue + loadResults: $0.apiClient.loadVocabResults(gameMode:timeScope:) ) } ), @@ -190,17 +188,22 @@ public let leaderboardReducer = Reducer< return .none case let .vocab(.tappedRow(id)): - struct CancelId: Hashable {} + enum CancelID {} guard let resultEnvelope = state.vocab.resultEnvelope else { return .none } - return environment.apiClient.apiRequest( - route: .leaderboard(.vocab(.fetchWord(wordId: .init(rawValue: id)))), - as: FetchVocabWordResponse.self - ) - .receive(on: environment.mainQueue) - .catchToEffect(LeaderboardAction.fetchWordResponse) - .cancellable(id: CancelId(), cancelInFlight: true) + + return .task { + await .fetchWordResponse( + TaskResult { + try await environment.apiClient.apiRequest( + route: .leaderboard(.vocab(.fetchWord(wordId: .init(rawValue: id)))), + as: FetchVocabWordResponse.self + ) + } + ) + } + .cancellable(id: CancelID.self, cancelInFlight: true) case .vocab: return .none @@ -313,11 +316,12 @@ public struct LeaderboardView: View { } extension ApiClient { + @Sendable func loadSoloResults( gameMode: GameMode, timeScope: TimeScope - ) -> Effect { - self.apiRequest( + ) async throws -> ResultEnvelope { + let response = try await self.apiRequest( route: .leaderboard( .fetch( gameMode: gameMode, @@ -327,34 +331,33 @@ extension ApiClient { ), as: FetchLeaderboardResponse.self ) - .compactMap { response in - response.entries.first.map { firstEntry in - ResultEnvelope( - outOf: firstEntry.outOf, - results: response.entries.map { entry in - ResultEnvelope.Result( - denseRank: entry.rank, - id: entry.id.rawValue, - isYourScore: entry.isYourScore, - rank: entry.rank, - score: entry.score, - subtitle: nil, - title: entry.playerDisplayName ?? (entry.isYourScore ? "You" : "Someone") - ) - } - ) - } + return response.entries.first.map { firstEntry in + ResultEnvelope( + outOf: firstEntry.outOf, + results: response.entries.map { entry in + ResultEnvelope.Result( + denseRank: entry.rank, + id: entry.id.rawValue, + isYourScore: entry.isYourScore, + rank: entry.rank, + score: entry.score, + subtitle: nil, + title: entry.playerDisplayName ?? (entry.isYourScore ? "You" : "Someone") + ) + } + ) } - .eraseToEffect() + ?? .init() } } extension ApiClient { + @Sendable func loadVocabResults( gameMode: GameMode, timeScope: TimeScope - ) -> Effect { - self.apiRequest( + ) async throws -> ResultEnvelope { + let response = try await self.apiRequest( route: .leaderboard( .vocab( .fetch( @@ -365,15 +368,13 @@ extension ApiClient { ), as: [FetchVocabLeaderboardResponse.Entry].self ) - .compactMap { response in - response.first.map { firstEntry in - ResultEnvelope( - outOf: firstEntry.outOf, - results: response.map(ResultEnvelope.Result.init) - ) - } + return response.first.map { firstEntry in + ResultEnvelope( + outOf: firstEntry.outOf, + results: response.map(ResultEnvelope.Result.init) + ) } - .eraseToEffect() + ?? .init() } } @@ -409,10 +410,11 @@ extension ResultEnvelope.Result { reducer: leaderboardReducer, environment: LeaderboardEnvironment( apiClient: update(.noop) { - $0.apiRequest = { route in + $0.apiRequest = { @Sendable route in switch route { case .leaderboard(.fetch(gameMode: _, language: _, timeScope: _)): - return Effect.ok( + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return try await OK( FetchLeaderboardResponse( entries: (1...20).map { idx in FetchLeaderboardResponse.Entry( @@ -427,11 +429,9 @@ extension ResultEnvelope.Result { } ) ) - .delay(for: 1, scheduler: DispatchQueue.main) - .eraseToEffect() default: - return .none + throw CancellationError() } } }, diff --git a/Sources/LeaderboardFeature/LeaderboardResultsView.swift b/Sources/LeaderboardFeature/LeaderboardResultsView.swift index 89611936..2957bc75 100644 --- a/Sources/LeaderboardFeature/LeaderboardResultsView.swift +++ b/Sources/LeaderboardFeature/LeaderboardResultsView.swift @@ -35,25 +35,22 @@ extension LeaderboardResultsState: Equatable where TimeScope: Equatable {} public enum LeaderboardResultsAction { case dismissTimeScopeMenu case gameModeButtonTapped(GameMode) - case resultsResponse(Result) - case onAppear + case resultsResponse(TaskResult) case tappedRow(id: UUID) case tappedTimeScopeLabel + case task case timeScopeChanged(TimeScope) } extension LeaderboardResultsAction: Equatable where TimeScope: Equatable {} public struct LeaderboardResultsEnvironment { - public let loadResults: (GameMode, TimeScope) -> Effect - public let mainQueue: AnySchedulerOf + public let loadResults: @Sendable (GameMode, TimeScope) async throws -> ResultEnvelope public init( - loadResults: @escaping (GameMode, TimeScope) -> Effect, - mainQueue: AnySchedulerOf + loadResults: @escaping @Sendable (GameMode, TimeScope) async throws -> ResultEnvelope ) { self.loadResults = loadResults - self.mainQueue = mainQueue } } @@ -74,18 +71,12 @@ extension Reducer { case let .gameModeButtonTapped(gameMode): state.gameMode = gameMode state.isLoading = true - return environment.loadResults(state.gameMode, state.timeScope) - .receive(on: environment.mainQueue.animation()) - .catchToEffect(LeaderboardResultsAction.resultsResponse) - - case .onAppear: - state.isLoading = true - state.isTimeScopeMenuVisible = false - state.resultEnvelope = .placeholder - - return environment.loadResults(state.gameMode, state.timeScope) - .receive(on: environment.mainQueue.animation()) - .catchToEffect(LeaderboardResultsAction.resultsResponse) + return .task { [timeScope = state.timeScope] in + await .resultsResponse( + TaskResult { try await environment.loadResults(gameMode, timeScope) } + ) + } + .animation() case .resultsResponse(.failure): state.isLoading = false @@ -104,14 +95,29 @@ extension Reducer { state.isTimeScopeMenuVisible.toggle() return .none + case .task: + state.isLoading = true + state.isTimeScopeMenuVisible = false + state.resultEnvelope = .placeholder + + return .task { [gameMode = state.gameMode, timeScope = state.timeScope] in + await .resultsResponse( + TaskResult { try await environment.loadResults(gameMode, timeScope) } + ) + } + .animation() + case let .timeScopeChanged(timeScope): state.isLoading = true state.isTimeScopeMenuVisible = false state.timeScope = timeScope - return environment.loadResults(state.gameMode, state.timeScope) - .receive(on: environment.mainQueue.animation()) - .catchToEffect(LeaderboardResultsAction.resultsResponse) + return .task { [gameMode = state.gameMode] in + await .resultsResponse( + TaskResult { try await environment.loadResults(gameMode, timeScope) } + ) + } + .animation() } } } @@ -209,7 +215,6 @@ where } self.subtitle - .animation(nil) .adaptiveFont(.matterMedium, size: 12) .frame(maxWidth: .infinity, alignment: .leading) .padding(.init(top: .grid(4), leading: .grid(5), bottom: .grid(3), trailing: .grid(5))) @@ -222,12 +227,11 @@ where ResultRow(color: self.color, result: result) } } - .animation(nil) if let result = self.viewStore.resultEnvelope?.nonContiguousResult { Image(systemName: "ellipsis") .opacity(0.4) - .adaptivePadding([.top, .bottom], .grid(5)) + .adaptivePadding(.vertical, .grid(5)) .adaptiveFont(.matterMedium, size: 16) Button(action: { self.viewStore.send(.tappedRow(id: result.id)) }) { @@ -250,7 +254,6 @@ where } .disabled(self.viewStore.isLoading) .redacted(reason: self.viewStore.isLoading ? .placeholder : []) - .animation(nil) } .background(self.color) .foregroundColor(.isowordsBlack) @@ -297,7 +300,7 @@ where alignment: .topTrailing ) - .onAppear { self.viewStore.send(.onAppear) } + .task { await self.viewStore.send(.task).finish() } } } @@ -412,24 +415,21 @@ extension ResultEnvelope { reducer: .leaderboardResultsReducer(), environment: LeaderboardResultsEnvironment( loadResults: { _, _ in - Effect( - value: .init( - outOf: 1000, - results: ([1, 2, 3, 4, 5, 6, 7, 7, 15]).map { index in - ResultEnvelope.Result( - denseRank: index, - id: UUID(), - isYourScore: index == 15, - rank: index, - score: 6000 - index * 300, - subtitle: "mbrandonw", - title: "Longword\(index)" - ) - } - ) + .init( + outOf: 1000, + results: ([1, 2, 3, 4, 5, 6, 7, 7, 15]).map { index in + ResultEnvelope.Result( + denseRank: index, + id: UUID(), + isYourScore: index == 15, + rank: index, + score: 6000 - index * 300, + subtitle: "mbrandonw", + title: "Longword\(index)" + ) + } ) - }, - mainQueue: .immediate + } ) ), title: Text("362,998 words"), @@ -452,25 +452,20 @@ extension ResultEnvelope { reducer: .leaderboardResultsReducer(), environment: LeaderboardResultsEnvironment( loadResults: { _, _ in - Effect( - value: .init( - outOf: 1000, - results: (1...5).map { index in - ResultEnvelope.Result( - denseRank: index, - id: UUID(), - isYourScore: index == 3, - rank: index, - score: 6000 - index * 800, - title: "Player \(index)" - ) - } - ) + .init( + outOf: 1000, + results: (1...5).map { index in + ResultEnvelope.Result( + denseRank: index, + id: UUID(), + isYourScore: index == 3, + rank: index, + score: 6000 - index * 800, + title: "Player \(index)" + ) + } ) - .delay(for: 1, scheduler: DispatchQueue.main.animation()) - .eraseToEffect() - }, - mainQueue: .immediate + } ) ), title: Text("Daily challenge"), @@ -492,8 +487,10 @@ extension ResultEnvelope { ), reducer: .leaderboardResultsReducer(), environment: LeaderboardResultsEnvironment( - loadResults: { _, _ in .init(error: .init(error: NSError(domain: "", code: 1))) }, - mainQueue: .immediate + loadResults: { _, _ in + struct Failure: Error {} + throw Failure() + } ) ), title: Text("Solo"), diff --git a/Sources/LocalDatabaseClient/Interface.swift b/Sources/LocalDatabaseClient/Interface.swift index f0e13925..e6dfa28c 100644 --- a/Sources/LocalDatabaseClient/Interface.swift +++ b/Sources/LocalDatabaseClient/Interface.swift @@ -2,12 +2,12 @@ import ComposableArchitecture import SharedModels public struct LocalDatabaseClient { - public var fetchGamesForWord: (String) -> Effect<[LocalDatabaseClient.Game], Error> - public var fetchStats: Effect - public var fetchVocab: Effect - public var migrate: Effect - public var playedGamesCount: (GameContext) -> Effect - public var saveGame: (CompletedGame) -> Effect + public var fetchGamesForWord: @Sendable (String) async throws -> [LocalDatabaseClient.Game] + public var fetchStats: @Sendable () async throws -> Stats + public var fetchVocab: @Sendable () async throws -> Vocab + public var migrate: @Sendable () async throws -> Void + public var playedGamesCount: @Sendable (GameContext) async throws -> Int + public var saveGame: @Sendable (CompletedGame) async throws -> Void public struct Game: Equatable { public var id: Int @@ -66,11 +66,11 @@ public struct LocalDatabaseClient { extension LocalDatabaseClient { public static let noop = Self( - fetchGamesForWord: { _ in .none }, - fetchStats: .none, - fetchVocab: .none, - migrate: .none, - playedGamesCount: { _ in .none }, - saveGame: { _ in .none } + fetchGamesForWord: { _ in try await Task.never() }, + fetchStats: { try await Task.never() }, + fetchVocab: { try await Task.never() }, + migrate: {}, + playedGamesCount: { _ in try await Task.never() }, + saveGame: { _ in try await Task.never() } ) } diff --git a/Sources/LocalDatabaseClient/Live.swift b/Sources/LocalDatabaseClient/Live.swift index 8daf688e..1a852740 100644 --- a/Sources/LocalDatabaseClient/Live.swift +++ b/Sources/LocalDatabaseClient/Live.swift @@ -5,31 +5,29 @@ import Sqlite extension LocalDatabaseClient { public static func live(path: URL) -> Self { - var _db: Sqlite! - func db() throws -> Sqlite { - if _db == nil { + let _db = UncheckedSendable(Box(wrappedValue: nil)) + @Sendable func db() throws -> Sqlite { + if _db.value.wrappedValue == nil { try! FileManager.default.createDirectory( at: path.deletingLastPathComponent(), withIntermediateDirectories: true ) - _db = try Sqlite(path: path.absoluteString) + _db.value.wrappedValue = try Sqlite(path: path.absoluteString) } - return _db + return _db.value.wrappedValue! } return Self( - fetchGamesForWord: { word in .catching { try db().fetchGames(for: word) } }, - fetchStats: .catching { try db().fetchStats() }, - fetchVocab: .catching { try db().fetchVocab() }, - migrate: .catching { try db().migrate() }, - playedGamesCount: { gameContext in - .catching { try db().playedGamesCount(gameContext: gameContext) } - }, - saveGame: { game in .catching { try db().saveGame(game) } } + fetchGamesForWord: { try db().fetchGames(for: $0) }, + fetchStats: { try db().fetchStats() }, + fetchVocab: { try db().fetchVocab() }, + migrate: { try db().migrate() }, + playedGamesCount: { try db().playedGamesCount(gameContext: $0) }, + saveGame: { try db().saveGame($0) } ) } public static func autoMigratingLive(path: URL) -> Self { let client = Self.live(path: path) - _ = client.migrate.sink(receiveCompletion: { _ in }, receiveValue: {}) + Task { try await client.migrate() } return client } } diff --git a/Sources/LocalDatabaseClient/Mocks.swift b/Sources/LocalDatabaseClient/Mocks.swift index 92b00819..b74bf729 100644 --- a/Sources/LocalDatabaseClient/Mocks.swift +++ b/Sources/LocalDatabaseClient/Mocks.swift @@ -2,35 +2,35 @@ import ComposableArchitecture import SharedModels import XCTestDynamicOverlay +class Box { + var wrappedValue: Value + init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + } +} + extension LocalDatabaseClient { public static var mock: Self { - var games: [CompletedGame] = [] + let games = UncheckedSendable(Box(wrappedValue: [CompletedGame]())) return Self( - fetchGamesForWord: { _ in .result { .success([]) } }, - fetchStats: Effect(value: Stats()), - fetchVocab: .none, - migrate: .result { .success(()) }, - playedGamesCount: { _ in .init(value: 10) }, - saveGame: { game in - .result { - games.append(game) - return .success(()) - } - } + fetchGamesForWord: { _ in [] }, + fetchStats: { Stats() }, + fetchVocab: { Vocab(words: []) }, + migrate: {}, + playedGamesCount: { _ in 10 }, + saveGame: { games.value.wrappedValue.append($0) } ) } #if DEBUG - public static let failing = Self( - fetchGamesForWord: { _ in - .failing("\(Self.self).fetchGamesForWord is unimplemented") - }, - fetchStats: .failing("\(Self.self).fetchStats is unimplemented"), - fetchVocab: .failing("\(Self.self).fetchVocab is unimplemented"), - migrate: .failing("\(Self.self).migrate is unimplemented"), - playedGamesCount: { _ in .failing("\(Self.self).playedGamesCount is unimplemented") }, - saveGame: { _ in .failing("\(Self.self).saveGame is unimplemented") } + public static let unimplemented = Self( + fetchGamesForWord: XCTUnimplemented("\(Self.self).fetchGamesForWord"), + fetchStats: XCTUnimplemented("\(Self.self).fetchStats"), + fetchVocab: XCTUnimplemented("\(Self.self).fetchVocab"), + migrate: XCTUnimplemented("\(Self.self).migrate"), + playedGamesCount: XCTUnimplemented("\(Self.self).playedGamesCount"), + saveGame: XCTUnimplemented("\(Self.self).saveGame") ) #endif } diff --git a/Sources/LowPowerModeClient/Client.swift b/Sources/LowPowerModeClient/Client.swift index ecf962ec..ac020c2c 100644 --- a/Sources/LowPowerModeClient/Client.swift +++ b/Sources/LowPowerModeClient/Client.swift @@ -3,5 +3,5 @@ import ComposableArchitecture import Foundation public struct LowPowerModeClient { - public var start: Effect + public var start: @Sendable () async -> AsyncStream } diff --git a/Sources/LowPowerModeClient/Live.swift b/Sources/LowPowerModeClient/Live.swift index 780660ec..7cfa3546 100644 --- a/Sources/LowPowerModeClient/Live.swift +++ b/Sources/LowPowerModeClient/Live.swift @@ -3,13 +3,21 @@ import Foundation extension LowPowerModeClient { public static var live = Self( - start: Publishers.Merge( - Deferred { Just(ProcessInfo.processInfo.isLowPowerModeEnabled) }, - - NotificationCenter.default - .publisher(for: .NSProcessInfoPowerStateDidChange) - .map { _ in ProcessInfo.processInfo.isLowPowerModeEnabled } - ) - .eraseToEffect() + start: { + AsyncStream { continuation in + continuation.yield(ProcessInfo.processInfo.isLowPowerModeEnabled) + let task = Task { + let powerStateDidChange = NotificationCenter.default + .notifications(named: .NSProcessInfoPowerStateDidChange) + .map { _ in ProcessInfo.processInfo.isLowPowerModeEnabled } + for await isLowPowerModeEnabled in powerStateDidChange { + continuation.yield(isLowPowerModeEnabled) + } + } + continuation.onTermination = { _ in + task.cancel() + } + } + } ) } diff --git a/Sources/LowPowerModeClient/Mocks.swift b/Sources/LowPowerModeClient/Mocks.swift index a8a1118a..38171efc 100644 --- a/Sources/LowPowerModeClient/Mocks.swift +++ b/Sources/LowPowerModeClient/Mocks.swift @@ -1,19 +1,40 @@ import Combine +import CombineSchedulers import ComposableArchitecture import Foundation +import XCTestDynamicOverlay extension LowPowerModeClient { - public static let `false` = Self(start: Just(false).eraseToEffect()) - public static let `true` = Self(start: Just(true).eraseToEffect()) + public static let `false` = Self( + start: { AsyncStream { $0.yield(false) } } + ) + + public static let `true` = Self( + start: { AsyncStream { $0.yield(true) } } + ) #if DEBUG - public static let failing = Self(start: .failing("\(Self.self).start is unimplemented")) + public static let unimplemented = Self( + start: XCTUnimplemented("\(Self.self).start") + ) public static var backAndForth: Self { Self( - start: Timer.publish(every: 2, on: .main, in: .default) - .autoconnect() - .scan(false) { a, _ in !a } - .eraseToEffect() + start: { + AsyncStream { continuation in + let isLowPowerModeEnabled = ActorIsolated(false) + Task { + await continuation.yield(isLowPowerModeEnabled.value) + for await _ in DispatchQueue.main.timer(interval: 2) { + let isLowPowerModeEnabled = await isLowPowerModeEnabled + .withValue { isLowPowerModeEnabled -> Bool in + isLowPowerModeEnabled.toggle() + return isLowPowerModeEnabled + } + continuation.yield(isLowPowerModeEnabled) + } + } + } + } ) } #endif diff --git a/Sources/MailgunClient/Mocks.swift b/Sources/MailgunClient/Mocks.swift index 89bdc630..9ed01067 100644 --- a/Sources/MailgunClient/Mocks.swift +++ b/Sources/MailgunClient/Mocks.swift @@ -2,9 +2,9 @@ import ServerTestHelpers #if DEBUG extension MailgunClient { - public static let failing = Self( + public static let unimplemented = Self( sendEmail: { _ in - .failing("\(Self.self).sendEmail is unimplemented") + .unimplemented("\(Self.self).sendEmail") } ) } diff --git a/Sources/MultiplayerFeature/MultiplayerView.swift b/Sources/MultiplayerFeature/MultiplayerView.swift index 3aabfebe..ccbf70bf 100644 --- a/Sources/MultiplayerFeature/MultiplayerView.swift +++ b/Sources/MultiplayerFeature/MultiplayerView.swift @@ -63,13 +63,7 @@ public let multiplayerReducer = Reducer< ._pullback( state: (\MultiplayerState.route).appending(path: /MultiplayerState.Route.pastGames), action: /MultiplayerAction.pastGames, - environment: { - PastGamesEnvironment( - backgroundQueue: $0.backgroundQueue, - gameCenter: $0.gameCenter, - mainQueue: $0.mainQueue - ) - } + environment: { PastGamesEnvironment(gameCenter: $0.gameCenter) } ), .init { state, action, environment in @@ -78,14 +72,12 @@ public let multiplayerReducer = Reducer< return .none case .startButtonTapped: - if environment.gameCenter.localPlayer.localPlayer().isAuthenticated { - return environment.gameCenter.turnBasedMatchmakerViewController - .present(showExistingMatches: false) - .fireAndForget() - - } else { - return environment.gameCenter.localPlayer.presentAuthenticationViewController - .fireAndForget() + return .fireAndForget { + if environment.gameCenter.localPlayer.localPlayer().isAuthenticated { + try await environment.gameCenter.turnBasedMatchmakerViewController.present(false) + } else { + await environment.gameCenter.localPlayer.presentAuthenticationViewController() + } } case .setNavigation(tag: .pastGames): diff --git a/Sources/MultiplayerFeature/PastGameRow.swift b/Sources/MultiplayerFeature/PastGameRow.swift index 268f3ea9..30ea857c 100644 --- a/Sources/MultiplayerFeature/PastGameRow.swift +++ b/Sources/MultiplayerFeature/PastGameRow.swift @@ -38,9 +38,9 @@ public struct PastGameState: Equatable, Identifiable { public enum PastGameAction: Equatable { case delegate(DelegateAction) case dismissAlert - case matchResponse(Result) + case matchResponse(TaskResult) case rematchButtonTapped - case rematchResponse(Result) + case rematchResponse(TaskResult) case tappedRow public enum DelegateAction: Equatable { @@ -50,7 +50,6 @@ public enum PastGameAction: Equatable { struct PastGameEnvironment { var gameCenter: GameCenterClient - var mainQueue: AnySchedulerOf } let pastGameReducer = Reducer { @@ -67,15 +66,11 @@ let pastGameReducer = Reducer) - case onAppear + case matchesResponse(TaskResult<[PastGameState]>) case pastGame(TurnBasedMatch.Id, PastGameAction) + case task } public struct PastGamesEnvironment { - public var backgroundQueue: AnySchedulerOf public var gameCenter: GameCenterClient - public var mainQueue: AnySchedulerOf } let pastGamesReducer = Reducer.combine( pastGameReducer.forEach( state: \.pastGames, action: /PastGamesAction.pastGame, - environment: { - PastGameEnvironment( - gameCenter: $0.gameCenter, - mainQueue: $0.mainQueue - ) - } + environment: { PastGameEnvironment(gameCenter: $0.gameCenter) } ), .init { state, action, environment in @@ -42,24 +35,25 @@ let pastGamesReducer = Reducer $1.endDate } - } - .mapError { $0 as NSError } - .receive(on: environment.mainQueue) - .catchToEffect(PastGamesAction.matchesResponse) - case .pastGame: return .none + + case .task: + return .task { + await .matchesResponse( + TaskResult { + try await environment.gameCenter.turnBasedMatch + .loadMatches() + .compactMap { match in + PastGameState( + turnBasedMatch: match, + localPlayerId: environment.gameCenter.localPlayer.localPlayer().gamePlayerId + ) + } + .sorted { $0.endDate > $1.endDate } + } + ) + } } } ) @@ -94,9 +88,7 @@ struct PastGamesView: View { ) .padding() } - .onAppear { - viewStore.send(.onAppear) - } + .task { await viewStore.send(.task).finish() } .navigationStyle( backgroundColor: self.colorScheme == .dark ? .isowordsBlack : .multiplayer, foregroundColor: self.colorScheme == .dark ? .multiplayer : .isowordsBlack, @@ -116,11 +108,7 @@ struct PastGamesView: View { store: .init( initialState: .init(pastGames: pastGames), reducer: pastGamesReducer, - environment: .init( - backgroundQueue: DispatchQueue.global(qos: .userInitiated).eraseToAnyScheduler(), - gameCenter: .noop, - mainQueue: .main - ) + environment: .init(gameCenter: .noop) ) ) } diff --git a/Sources/NotificationHelpers/NotificationHelpers.swift b/Sources/NotificationHelpers/NotificationHelpers.swift index eab6e6f9..eb80d31b 100644 --- a/Sources/NotificationHelpers/NotificationHelpers.swift +++ b/Sources/NotificationHelpers/NotificationHelpers.swift @@ -3,20 +3,12 @@ import ComposableArchitecture import ComposableUserNotifications import RemoteNotificationsClient -extension Effect where Output == Never, Failure == Never { - public static func registerForRemoteNotifications( - remoteNotifications: RemoteNotificationsClient, - scheduler: S, - userNotifications: UserNotificationClient - ) -> Self { - userNotifications.getNotificationSettings - .receive(on: scheduler) - .flatMap { settings in - settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional - ? remoteNotifications.register() - : .none - } - .receive(on: scheduler) - .eraseToEffect() - } +public func registerForRemoteNotificationsAsync( + remoteNotifications: RemoteNotificationsClient, + userNotifications: UserNotificationClient +) async { + let settings = await userNotifications.getNotificationSettings() + guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional + else { return } + await remoteNotifications.register() } diff --git a/Sources/NotificationsAuthAlert/NotificationsAuthAlert.swift b/Sources/NotificationsAuthAlert/NotificationsAuthAlert.swift index dc95795d..40a0f580 100644 --- a/Sources/NotificationsAuthAlert/NotificationsAuthAlert.swift +++ b/Sources/NotificationsAuthAlert/NotificationsAuthAlert.swift @@ -45,36 +45,28 @@ public let notificationsAuthAlertReducer = Reducer< > { state, action, environment in switch action { case .closeButtonTapped: - return Effect(value: .delegate(.close)) - .receive(on: ImmediateScheduler.shared.animation()) - .eraseToEffect() + return .task { .delegate(.close) }.animation() case .delegate: return .none case .turnOnNotificationsButtonTapped: - return .concatenate( - environment.userNotifications.requestAuthorization([.alert, .sound]) - .ignoreFailure() - .flatMap { successful in - successful - ? Effect.registerForRemoteNotifications( - remoteNotifications: environment.remoteNotifications, - scheduler: environment.mainRunLoop, - userNotifications: environment.userNotifications - ) - : .none - } - .eraseToEffect() - .fireAndForget(), - - environment.userNotifications.getNotificationSettings - .flatMap { settings in - Effect(value: .delegate(.didChooseNotificationSettings(settings))) - .receive(on: environment.mainRunLoop.animation()) - } - .eraseToEffect() - ) + return .run { send in + if try await environment.userNotifications.requestAuthorization([.alert, .sound]) { + await registerForRemoteNotificationsAsync( + remoteNotifications: environment.remoteNotifications, + userNotifications: environment.userNotifications + ) + } + await send( + .delegate( + .didChooseNotificationSettings( + environment.userNotifications.getNotificationSettings() + ) + ), + animation: .default + ) + } } } diff --git a/Sources/OnboardingFeature/OnboardingStepView.swift b/Sources/OnboardingFeature/OnboardingStepView.swift index e5eaa108..bf25f564 100644 --- a/Sources/OnboardingFeature/OnboardingStepView.swift +++ b/Sources/OnboardingFeature/OnboardingStepView.swift @@ -331,7 +331,7 @@ struct OnboardingStepView: View { ) } } - .onAppear { self.viewStore.send(.onAppear) } + .task { await self.viewStore.send(.task).finish() } .alert( self.store.scope(state: \.alert, action: OnboardingAction.alert), dismiss: .dismiss diff --git a/Sources/OnboardingFeature/OnboardingView.swift b/Sources/OnboardingFeature/OnboardingView.swift index 73507e23..eb44539d 100644 --- a/Sources/OnboardingFeature/OnboardingView.swift +++ b/Sources/OnboardingFeature/OnboardingView.swift @@ -137,12 +137,11 @@ public enum OnboardingAction: Equatable { case delegate(DelegateAction) case game(GameAction) case getStartedButtonTapped - case onAppear case nextButtonTapped case skipButtonTapped + case task public enum AlertAction: Equatable { - case confirmSkipButtonTapped case dismiss case resumeButtonTapped case skipButtonTapped @@ -202,7 +201,7 @@ public struct OnboardingEnvironment { mainRunLoop: self.mainRunLoop, remoteNotifications: .noop, serverConfig: .noop, - setUserInterfaceStyle: { _ in .none }, + setUserInterfaceStyle: { _ in }, storeKit: .noop, userDefaults: self.userDefaults, userNotifications: .noop @@ -216,41 +215,29 @@ public let onboardingReducer = Reducer< OnboardingEnvironment > { state, action, environment in switch action { - case .alert(.confirmSkipButtonTapped): - state.alert = nil - state.step = OnboardingState.Step.allCases.last! - return .none - case .alert(.dismiss), .alert(.resumeButtonTapped): state.alert = nil return .none case .alert(.skipButtonTapped): state.alert = nil - return .merge( - Effect(value: .alert(.confirmSkipButtonTapped)) - .receive(on: ImmediateScheduler.shared.animation()) - .eraseToEffect(), + state.step = OnboardingState.Step.allCases.last! - environment.audioPlayer.play(.uiSfxTap) - .fireAndForget() - ) + return .fireAndForget { + await environment.audioPlayer.play(.uiSfxTap) + await Task.cancel(id: DelayedNextStepID.self) + } case .delayedNextStep: state.step.next() return .none case .delegate(.getStarted): - return .merge( - environment.userDefaults - .setHasShownFirstLaunchOnboarding(true) - .fireAndForget(), - - environment.audioPlayer.stop(.onboardingBgMusic) - .fireAndForget(), - - .cancel(id: DelayedNextStepId()) - ) + return .fireAndForget { + await environment.userDefaults.setHasShownFirstLaunchOnboarding(true) + await environment.audioPlayer.stop(.onboardingBgMusic) + await Task.cancel(id: DelayedNextStepID.self) + } case .game where state.step.isCongratsStep: return .none @@ -286,7 +273,7 @@ public let onboardingReducer = Reducer< case let .game(.doubleTap(index: index)): guard state.step == .some(.step19_DoubleTapToRemove) else { return .none } - return .init(value: .game(.confirmRemoveCube(index))) + return .task { .game(.confirmRemoveCube(index)) } case let .game(.tap(gestureState, .some(indexedCubeFace))): let index = @@ -320,58 +307,18 @@ public let onboardingReducer = Reducer< ) case .getStartedButtonTapped: - return .init(value: .delegate(.getStarted)) - - case .onAppear: - var firstStepDelay: Int { - switch state.presentationStyle { - case .demo, .firstLaunch: - return 4 - case .help: - return 2 - } - } - - return .merge( - environment.audioPlayer.load(AudioPlayerClient.Sound.allCases) - .fireAndForget(), - - Effect - .catching { try environment.dictionary.load(.en) } - .subscribe(on: environment.backgroundQueue) - .receive(on: environment.mainQueue) - .fireAndForget(), - - state.step == OnboardingState.Step.allCases[0] - ? Effect(value: .delayedNextStep) - .delay(for: .seconds(firstStepDelay), scheduler: environment.mainQueue.animation()) - .eraseToEffect() - .cancellable(id: DelayedNextStepId()) - : .none, - - environment.audioPlayer.play( - state.presentationStyle == .demo - ? .timedGameBgLoop1 - : .onboardingBgMusic - ) - .fireAndForget() - ) + return .task { .delegate(.getStarted) } case .nextButtonTapped: state.step.next() - return environment.audioPlayer.play(.uiSfxTap) - .fireAndForget() + return .fireAndForget { await environment.audioPlayer.play(.uiSfxTap) } case .skipButtonTapped: guard !environment.userDefaults.hasShownFirstLaunchOnboarding else { - return .merge( - Effect(value: .delegate(.getStarted)) - .receive(on: ImmediateScheduler.shared.animation()) - .eraseToEffect(), - - environment.audioPlayer.play(.uiSfxTap) - .fireAndForget() - ) + return .run { send in + await send(.delegate(.getStarted), animation: .default) + await environment.audioPlayer.play(.uiSfxTap) + } } state.alert = .init( title: .init("Skip tutorial?"), @@ -386,8 +333,31 @@ public let onboardingReducer = Reducer< ), secondaryButton: .default(.init("No, resume"), action: .send(.resumeButtonTapped)) ) - return environment.audioPlayer.play(.uiSfxTap) - .fireAndForget() + return .fireAndForget { await environment.audioPlayer.play(.uiSfxTap) } + + case .task: + let firstStepDelay: Int = { + switch state.presentationStyle { + case .demo, .firstLaunch: + return 4 + case .help: + return 2 + } + }() + + return .run { [step = state.step, presentationStyle = state.presentationStyle] send in + await environment.audioPlayer.load(AudioPlayerClient.Sound.allCases) + _ = try environment.dictionary.load(.en) + await environment.audioPlayer.play( + presentationStyle == .demo ? .timedGameBgLoop1 : .onboardingBgMusic + ) + + if step == OnboardingState.Step.allCases[0] { + try await environment.mainQueue.sleep(for: .seconds(firstStepDelay)) + await send(.delayedNextStep, animation: .default) + } + } + .cancellable(id: DelayedNextStepID.self) } } .onChange(of: \.game.selectedWordString) { selectedWord, state, _, _ in @@ -425,17 +395,20 @@ public let onboardingReducer = Reducer< return .none case .step13_Congrats: - return Effect(value: .delayedNextStep) - .delay(for: 3, scheduler: environment.mainQueue.animation()) - .eraseToEffect() + return .task { + try await environment.mainQueue.sleep(for: .seconds(3)) + return .delayedNextStep + } + .animation() case .step6_Congrats, .step9_Congrats, .step17_Congrats, .step20_Congrats: - return Effect(value: .delayedNextStep) - .delay(for: 2, scheduler: environment.mainQueue.animation()) - .eraseToEffect() + return .task { + try await environment.mainQueue.sleep(for: .seconds(2)) + return .delayedNextStep + } } } @@ -544,7 +517,7 @@ private let onboardingGameReducer = gameReducer( isHapticsEnabled: { _ in true } ) -private struct DelayedNextStepId: Hashable {} +private enum DelayedNextStepID: Hashable {} #if DEBUG struct OnboardingView_Previews: PreviewProvider { diff --git a/Sources/RemoteNotificationsClient/Interface.swift b/Sources/RemoteNotificationsClient/Interface.swift index e25c9153..17371203 100644 --- a/Sources/RemoteNotificationsClient/Interface.swift +++ b/Sources/RemoteNotificationsClient/Interface.swift @@ -1,16 +1,16 @@ import ComposableArchitecture public struct RemoteNotificationsClient { - public var isRegistered: () -> Bool - public var register: () -> Effect - public var unregister: () -> Effect + public var isRegistered: @Sendable () async -> Bool + public var register: @Sendable () async -> Void + public var unregister: @Sendable () async -> Void } extension RemoteNotificationsClient { public static let noop = Self( isRegistered: { true }, - register: { .none }, - unregister: { .none } + register: {}, + unregister: {} ) } @@ -18,13 +18,10 @@ extension RemoteNotificationsClient { import XCTestDynamicOverlay extension RemoteNotificationsClient { - public static let failing = Self( - isRegistered: { - XCTFail("\(Self.self).isRegistered is unimplemented") - return false - }, - register: { .failing("\(Self.self).register is unimplemented") }, - unregister: { .failing("\(Self.self).unregister is unimplemented") } + public static let unimplemented = Self( + isRegistered: XCTUnimplemented("\(Self.self).isRegistered", placeholder: false), + register: XCTUnimplemented("\(Self.self).register"), + unregister: XCTUnimplemented("\(Self.self).unregister") ) } #endif diff --git a/Sources/RemoteNotificationsClient/Live.swift b/Sources/RemoteNotificationsClient/Live.swift index 8fb6768c..0da23ef6 100644 --- a/Sources/RemoteNotificationsClient/Live.swift +++ b/Sources/RemoteNotificationsClient/Live.swift @@ -1,38 +1,10 @@ -#if canImport(UIKit) - import UIKit +import UIKit - @available(iOSApplicationExtension, unavailable) - extension RemoteNotificationsClient { - public static let live = Self( - isRegistered: { UIApplication.shared.isRegisteredForRemoteNotifications }, - register: { - .fireAndForget { - UIApplication.shared.registerForRemoteNotifications() - } - }, - unregister: { - .fireAndForget { - UIApplication.shared.unregisterForRemoteNotifications() - } - } - ) - } -#elseif canImport(AppKit) - import AppKit - - extension RemoteNotificationsClient { - public static let live = Self( - isRegistered: { NSApplication.shared.isRegisteredForRemoteNotifications }, - register: { - .fireAndForget { - NSApplication.shared.registerForRemoteNotifications() - } - }, - unregister: { - .fireAndForget { - NSApplication.shared.unregisterForRemoteNotifications() - } - } - ) - } -#endif +@available(iOSApplicationExtension, unavailable) +extension RemoteNotificationsClient { + public static let live = Self( + isRegistered: { await UIApplication.shared.isRegisteredForRemoteNotifications }, + register: { await UIApplication.shared.registerForRemoteNotifications() }, + unregister: { await UIApplication.shared.unregisterForRemoteNotifications() } + ) +} diff --git a/Sources/SelectionSoundsCore/SelectionSoundsCore.swift b/Sources/SelectionSoundsCore/SelectionSoundsCore.swift index 7a99369b..061a8925 100644 --- a/Sources/SelectionSoundsCore/SelectionSoundsCore.swift +++ b/Sources/SelectionSoundsCore/SelectionSoundsCore.swift @@ -13,42 +13,33 @@ extension Reducer { ) -> Reducer { self .onChange(of: selectedWord) { previousSelection, selectedWord, state, _, environment in - var effects: [Effect] = [] + return .fireAndForget { [state] in + if let noteIndex = noteIndex( + selectedWord: selectedWord, + cubes: puzzle(state), + notes: AudioPlayerClient.Sound.allNotes + ) { + await audioPlayer(environment).play(AudioPlayerClient.Sound.allNotes[noteIndex]) + } - if let noteIndex = noteIndex( - selectedWord: selectedWord, - cubes: puzzle(state), - notes: AudioPlayerClient.Sound.allNotes - ) { - effects.append( - audioPlayer(environment).play(AudioPlayerClient.Sound.allNotes[noteIndex]) - .fireAndForget() - ) - } - - let selectedWordString = puzzle(state).string(from: selectedWord) - if !hasBeenPlayed(state, selectedWordString) - && contains(state, environment, selectedWordString) - { - - let validCount = selectedWordString - .indices - .dropFirst(2) - .reduce(into: 0) { count, index in - count += - contains(state, environment, String(selectedWordString[...index])) - ? 1 - : 0 + let selectedWordString = puzzle(state).string(from: selectedWord) + if !hasBeenPlayed(state, selectedWordString) + && contains(state, environment, selectedWordString) + { + let validCount = selectedWordString + .indices + .dropFirst(2) + .reduce(into: 0) { count, index in + count += + contains(state, environment, String(selectedWordString[...index])) + ? 1 + : 0 + } + if validCount > 0 { + await audioPlayer(environment).play(.validWord(level: validCount)) } - effects.append( - validCount > 0 - ? audioPlayer(environment).play(.validWord(level: validCount)) - .fireAndForget() - : .none - ) + } } - - return .merge(effects) } } } diff --git a/Sources/ServerConfigClient/Client.swift b/Sources/ServerConfigClient/Client.swift index 11c6862a..23374f15 100644 --- a/Sources/ServerConfigClient/Client.swift +++ b/Sources/ServerConfigClient/Client.swift @@ -3,5 +3,5 @@ import ComposableArchitecture public struct ServerConfigClient { public var config: () -> ServerConfig - public var refresh: () -> Effect + public var refresh: @Sendable () async throws -> ServerConfig } diff --git a/Sources/ServerConfigClient/Live.swift b/Sources/ServerConfigClient/Live.swift index 9eeeed2e..b8071754 100644 --- a/Sources/ServerConfigClient/Live.swift +++ b/Sources/ServerConfigClient/Live.swift @@ -3,26 +3,20 @@ import ServerConfig extension ServerConfigClient { public static func live( - fetch: @escaping () -> Effect + fetch: @escaping @Sendable () async throws -> ServerConfig ) -> Self { - var currentConfig = - (UserDefaults.standard.object(forKey: serverConfigKey) as? Data) - .flatMap { try? jsonDecoder.decode(ServerConfig.self, from: $0) } - ?? ServerConfig() - - return Self( - config: { currentConfig }, + Self( + config: { + (UserDefaults.standard.object(forKey: serverConfigKey) as? Data) + .flatMap { try? jsonDecoder.decode(ServerConfig.self, from: $0) } + ?? ServerConfig() + }, refresh: { - fetch() - .handleEvents( - receiveOutput: { - currentConfig = $0 - guard let data = try? jsonEncoder.encode(currentConfig) - else { return } - UserDefaults.standard.set(data, forKey: serverConfigKey) - } - ) - .eraseToEffect() + let config = try await fetch() + if let data = try? jsonEncoder.encode(config) { + UserDefaults.standard.set(data, forKey: serverConfigKey) + } + return config } ) } diff --git a/Sources/ServerConfigClient/Mocks.swift b/Sources/ServerConfigClient/Mocks.swift index 244549b3..60504c3f 100644 --- a/Sources/ServerConfigClient/Mocks.swift +++ b/Sources/ServerConfigClient/Mocks.swift @@ -3,7 +3,7 @@ import ServerConfig extension ServerConfigClient { public static let noop = Self( config: { .init() }, - refresh: { .none } + refresh: { try await Task.never() } ) } @@ -11,12 +11,9 @@ extension ServerConfigClient { import XCTestDynamicOverlay extension ServerConfigClient { - public static let failing = Self( - config: { - XCTFail("\(Self.self).config is unimplemented") - return .init() - }, - refresh: { .failing("\(Self.self).refresh is unimplemented") } + public static let unimplemented = Self( + config: XCTUnimplemented("\(Self.self).config", placeholder: ServerConfig()), + refresh: XCTUnimplemented("\(Self.self).refresh") ) } #endif diff --git a/Sources/ServerRouter/Router.swift b/Sources/ServerRouter/Router.swift index f375e0ff..3450e2a9 100644 --- a/Sources/ServerRouter/Router.swift +++ b/Sources/ServerRouter/Router.swift @@ -321,16 +321,13 @@ public struct ServerRouter: ParserPrinter { sha256: { $0 } ) - public static let failing = Self( - date: { - XCTFail("\(Self.self).date is unimplemented") - return .init() - }, + public static let unimplemented = Self( + date: XCTUnimplemented("\(Self.self).date", placeholder: Date()), decoder: jsonDecoder, encoder: jsonEncoder, secrets: ["SECRET_DEADBEEF"], sha256: { - XCTFail("\(Self.self).sha256 is unimplemented") + XCTFail("Unimplemented: \(Self.self).sha256") return $0 } ) diff --git a/Sources/ServerTestHelpers/FailingEitherIO.swift b/Sources/ServerTestHelpers/UnimplementedEitherIO.swift similarity index 70% rename from Sources/ServerTestHelpers/FailingEitherIO.swift rename to Sources/ServerTestHelpers/UnimplementedEitherIO.swift index eb55bef5..b36690f0 100644 --- a/Sources/ServerTestHelpers/FailingEitherIO.swift +++ b/Sources/ServerTestHelpers/UnimplementedEitherIO.swift @@ -3,8 +3,9 @@ import XCTestDynamicOverlay extension EitherIO where E == Error { - public static func failing(_ message: String) -> Self { - .init( + public static func unimplemented(_ message: String) -> Self { + let message = "Unimplemented\(message.isEmpty ? "" : ": \(message)")" + return .init( run: .init { XCTFail(message) return .left(AnError(message: message)) diff --git a/Sources/SettingsFeature/AppearanceSettingsView.swift b/Sources/SettingsFeature/AppearanceSettingsView.swift index 85543269..4494db91 100644 --- a/Sources/SettingsFeature/AppearanceSettingsView.swift +++ b/Sources/SettingsFeature/AppearanceSettingsView.swift @@ -152,7 +152,6 @@ struct ColorSchemePicker: View { isSelected: self.colorScheme == colorScheme ) ) - .animation(self.colorScheme == colorScheme ? .default : nil) .frame(maxWidth: .infinity) .adaptiveFont(.matterMedium, size: 14) } diff --git a/Sources/SettingsFeature/FileClientEffects.swift b/Sources/SettingsFeature/FileClientEffects.swift index c615aa4c..63acfe08 100644 --- a/Sources/SettingsFeature/FileClientEffects.swift +++ b/Sources/SettingsFeature/FileClientEffects.swift @@ -2,14 +2,12 @@ import ComposableArchitecture import FileClient extension FileClient { - public func loadUserSettings() -> Effect, Never> { - self.load(UserSettings.self, from: userSettingsFileName) + public func loadUserSettings() async throws -> UserSettings { + try await self.load(UserSettings.self, from: userSettingsFileName) } - public func saveUserSettings( - userSettings: UserSettings, on queue: AnySchedulerOf - ) -> Effect { - self.save(userSettings, to: userSettingsFileName, on: queue) + public func save(userSettings: UserSettings) async throws -> Void { + try await self.save(userSettings, to: userSettingsFileName) } } diff --git a/Sources/SettingsFeature/Settings.swift b/Sources/SettingsFeature/Settings.swift index de6b90a9..f91d5efe 100644 --- a/Sources/SettingsFeature/Settings.swift +++ b/Sources/SettingsFeature/Settings.swift @@ -171,19 +171,19 @@ public struct SettingsState: Equatable { public enum SettingsAction: BindableAction, Equatable { case binding(BindingAction) - case currentPlayerRefreshed(Result) + case currentPlayerRefreshed(TaskResult) case didBecomeActive case leaveUsAReviewButtonTapped - case onAppear case onDismiss case openSettingButtonTapped case paymentTransaction(StoreKitClient.PaymentTransactionObserverEvent) - case productsResponse(Result) + case productsResponse(TaskResult) case reportABugButtonTapped case restoreButtonTapped case stats(StatsAction) case tappedProduct(StoreKitClient.Product) - case userNotificationAuthorizationResponse(Result) + case task + case userNotificationAuthorizationResponse(TaskResult) case userNotificationSettingsResponse(UserNotificationClient.Notification.Settings) } @@ -200,7 +200,7 @@ public struct SettingsEnvironment { public var mainQueue: AnySchedulerOf public var remoteNotifications: RemoteNotificationsClient public var serverConfig: ServerConfigClient - public var setUserInterfaceStyle: (UIUserInterfaceStyle) -> Effect + public var setUserInterfaceStyle: @Sendable (UIUserInterfaceStyle) async -> Void public var storeKit: StoreKitClient public var userDefaults: UserDefaultsClient public var userNotifications: UserNotificationClient @@ -218,7 +218,7 @@ public struct SettingsEnvironment { mainQueue: AnySchedulerOf, remoteNotifications: RemoteNotificationsClient, serverConfig: ServerConfigClient, - setUserInterfaceStyle: @escaping (UIUserInterfaceStyle) -> Effect, + setUserInterfaceStyle: @escaping @Sendable (UIUserInterfaceStyle) async -> Void, storeKit: StoreKitClient, userDefaults: UserDefaultsClient, userNotifications: UserNotificationClient @@ -246,25 +246,23 @@ public struct SettingsEnvironment { import XCTestDynamicOverlay extension SettingsEnvironment { - public static let failing = Self( - apiClient: .failing, - applicationClient: .failing, - audioPlayer: .failing, - backgroundQueue: .failing("backgroundQueue"), - build: .failing, - database: .failing, - feedbackGenerator: .failing, - fileClient: .failing, - lowPowerMode: .failing, - mainQueue: .failing("mainQueue"), - remoteNotifications: .failing, - serverConfig: .failing, - setUserInterfaceStyle: { _ in - .failing("\(Self.self).setUserInterfaceStyle is unimplemented") - }, - storeKit: .failing, - userDefaults: .failing, - userNotifications: .failing + public static let unimplemented = Self( + apiClient: .unimplemented, + applicationClient: .unimplemented, + audioPlayer: .unimplemented, + backgroundQueue: .unimplemented("backgroundQueue"), + build: .unimplemented, + database: .unimplemented, + feedbackGenerator: .unimplemented, + fileClient: .unimplemented, + lowPowerMode: .unimplemented, + mainQueue: .unimplemented("mainQueue"), + remoteNotifications: .unimplemented, + serverConfig: .unimplemented, + setUserInterfaceStyle: XCTUnimplemented("\(Self.self).setUserInterfaceStyle"), + storeKit: .unimplemented, + userDefaults: .unimplemented, + userNotifications: .unimplemented ) public static let noop = Self( @@ -280,7 +278,7 @@ public struct SettingsEnvironment { mainQueue: .immediate, remoteNotifications: .noop, serverConfig: .noop, - setUserInterfaceStyle: { _ in .none }, + setUserInterfaceStyle: { _ in }, storeKit: .noop, userDefaults: .noop, userNotifications: .noop @@ -304,16 +302,15 @@ public let settingsReducer = Reducer - if state.isFullGamePurchased { - loadProductsEffect = .none - } else { - loadProductsEffect = environment.storeKit - .fetchProducts([ - environment.serverConfig.config().productIdentifiers.fullGame - ]) - .mapError { $0 as NSError } - .receive(on: environment.mainQueue.animation()) - .catchToEffect(SettingsAction.productsResponse) + return .task { + await .userNotificationSettingsResponse( + environment.userNotifications.getNotificationSettings() + ) } - if let baseUrl = DeveloperSettings.BaseUrl( - rawValue: environment.apiClient.baseUrl().absoluteString) - { - state.developer.currentBaseUrl = baseUrl + case .leaveUsAReviewButtonTapped: + return .fireAndForget { + _ = await environment.applicationClient + .open(environment.serverConfig.config().appStoreReviewUrl, [:]) } - return .merge( - loadProductsEffect, - - environment.storeKit.observer - .receive(on: environment.mainQueue.animation()) - .map(SettingsAction.paymentTransaction) - .eraseToEffect() - .cancellable(id: PaymentObserverId()), - - environment.userNotifications.getNotificationSettings - .receive(on: environment.mainQueue.animation()) - .eraseToEffect() - .map(SettingsAction.userNotificationSettingsResponse), - - NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) - .map { _ in .didBecomeActive } - .eraseToEffect() - .cancellable(id: DidBecomeActiveId()) - ) - case .onDismiss: - return .merge( - .cancel(id: DidBecomeActiveId()), - .cancel(id: PaymentObserverId()) - ) + return .cancel(id: PaymentObserverID.self) case .paymentTransaction(.removedTransactions): state.isPurchasing = false - return environment.apiClient.refreshCurrentPlayer() - .receive(on: environment.mainQueue.animation()) - .catchToEffect(SettingsAction.currentPlayerRefreshed) + return .task { + await .currentPlayerRefreshed( + TaskResult { try await environment.apiClient.refreshCurrentPlayer() } + ) + } + .animation() case let .paymentTransaction(.restoreCompletedTransactionsFinished(transactions)): state.isRestoring = false @@ -502,15 +456,19 @@ public let settingsReducer = Reducer public let accessToken: AccessToken diff --git a/Sources/SharedModels/GameMode.swift b/Sources/SharedModels/GameMode.swift index dcbaad74..93ea29be 100644 --- a/Sources/SharedModels/GameMode.swift +++ b/Sources/SharedModels/GameMode.swift @@ -1,4 +1,4 @@ -public enum GameMode: String, CaseIterable, Codable, Equatable, Hashable, Identifiable { +public enum GameMode: String, CaseIterable, Codable, Hashable, Identifiable, Sendable { case timed case unlimited diff --git a/Sources/SharedModels/IndexedCubeFace.swift b/Sources/SharedModels/IndexedCubeFace.swift index ff8a9a42..85cdf53d 100644 --- a/Sources/SharedModels/IndexedCubeFace.swift +++ b/Sources/SharedModels/IndexedCubeFace.swift @@ -1,6 +1,6 @@ import CustomDump -public struct IndexedCubeFace: Codable, Equatable, Hashable { +public struct IndexedCubeFace: Codable, Hashable, Sendable { public var index: LatticePoint public var side: CubeFace.Side diff --git a/Sources/SharedModels/Language.swift b/Sources/SharedModels/Language.swift index bab0953c..c8b3c226 100644 --- a/Sources/SharedModels/Language.swift +++ b/Sources/SharedModels/Language.swift @@ -1,3 +1,3 @@ -public enum Language: String, CaseIterable, Codable, Equatable { +public enum Language: String, CaseIterable, Codable, Equatable, Sendable { case en } diff --git a/Sources/SharedModels/LatticePoint.swift b/Sources/SharedModels/LatticePoint.swift index 4d797fcc..3217eb9c 100644 --- a/Sources/SharedModels/LatticePoint.swift +++ b/Sources/SharedModels/LatticePoint.swift @@ -1,7 +1,7 @@ import CustomDump -public struct LatticePoint: Codable, Equatable, Hashable { - public enum Index: Int, CaseIterable, Codable, Comparable { +public struct LatticePoint: Codable, Hashable, Sendable { + public enum Index: Int, CaseIterable, Codable, Comparable, Sendable { case zero = 0 case one = 1 case two = 2 diff --git a/Sources/SharedModels/Move.swift b/Sources/SharedModels/Move.swift index 0b192c1f..e8af657d 100644 --- a/Sources/SharedModels/Move.swift +++ b/Sources/SharedModels/Move.swift @@ -1,7 +1,7 @@ import Foundation import Tagged -public struct Move: Codable, Equatable { +public struct Move: Codable, Equatable, Sendable { public var playedAt: Date public var playerIndex: PlayerIndex? public var reactions: [PlayerIndex: Reaction]? @@ -24,7 +24,7 @@ public struct Move: Codable, Equatable { self.type = type } - public enum MoveType: Codable, Equatable { + public enum MoveType: Codable, Equatable, Sendable { case playedWord([IndexedCubeFace]) case removedCube(LatticePoint) @@ -64,7 +64,7 @@ public struct Move: Codable, Equatable { } } - public struct Reaction: CaseIterable, Codable, Equatable, Hashable, Identifiable, RawRepresentable + public struct Reaction: CaseIterable, Codable, Hashable, Identifiable, RawRepresentable, Sendable { public let rawValue: String diff --git a/Sources/SharedModels/Moves.swift b/Sources/SharedModels/Moves.swift index f5309346..2d204af5 100644 --- a/Sources/SharedModels/Moves.swift +++ b/Sources/SharedModels/Moves.swift @@ -3,7 +3,8 @@ public struct Moves: Codable, Equatable, ExpressibleByArrayLiteral, - RangeReplaceableCollection + RangeReplaceableCollection, + Sendable { var rawValue: [Move] diff --git a/Sources/SharedModels/PushAuthorizationStatus.swift b/Sources/SharedModels/PushAuthorizationStatus.swift index caba64af..e7b762cc 100644 --- a/Sources/SharedModels/PushAuthorizationStatus.swift +++ b/Sources/SharedModels/PushAuthorizationStatus.swift @@ -1,4 +1,4 @@ -public struct PushAuthorizationStatus: RawRepresentable, Codable, Equatable { +public struct PushAuthorizationStatus: RawRepresentable, Codable, Equatable, Sendable { public static let authorized = Self(rawValue: 2) public static let denied = Self(rawValue: 1) public static let ephemeral = Self(rawValue: 4) diff --git a/Sources/SharedModels/Three.swift b/Sources/SharedModels/Three.swift index b712a000..5949afb5 100644 --- a/Sources/SharedModels/Three.swift +++ b/Sources/SharedModels/Three.swift @@ -80,6 +80,7 @@ extension Three: Encodable where Element: Encodable { extension Three: Equatable where Element: Equatable {} extension Three: Hashable where Element: Hashable {} +extension Three: Sendable where Element: Sendable {} extension Three: CustomDumpReflectable { public var customDumpMirror: Mirror { diff --git a/Sources/SharedModels/TimeScope.swift b/Sources/SharedModels/TimeScope.swift index f7cbe098..234c73a2 100644 --- a/Sources/SharedModels/TimeScope.swift +++ b/Sources/SharedModels/TimeScope.swift @@ -1,4 +1,4 @@ -public enum TimeScope: String, CaseIterable, Codable { +public enum TimeScope: String, CaseIterable, Codable, Sendable { case allTime case lastDay case lastWeek diff --git a/Sources/SiteMiddleware/ServerEnvironment.swift b/Sources/SiteMiddleware/ServerEnvironment.swift index 4068d49a..68e0cac5 100644 --- a/Sources/SiteMiddleware/ServerEnvironment.swift +++ b/Sources/SiteMiddleware/ServerEnvironment.swift @@ -52,26 +52,17 @@ public struct ServerEnvironment { import XCTestDynamicOverlay extension ServerEnvironment { - public static let failing = Self( - changelog: { - XCTFail("changelog is unimplemented.") - return .current - }, - database: .failing, - date: { - XCTFail("date is unimplemented.") - return .init() - }, - dictionary: .failing, + public static let unimplemented = Self( + changelog: XCTUnimplemented("\(Self.self).changelog", placeholder: .current), + database: .unimplemented, + date: XCTUnimplemented("\(Self.self).date", placeholder: Date()), + dictionary: .unimplemented, envVars: EnvVars(appEnv: .testing), - itunes: .failing, - mailgun: .failing, - randomCubes: { - XCTFail("randomCubes is unimplemented.") - return .mock - }, - router: .failing, - snsClient: .failing + itunes: .unimplemented, + mailgun: .unimplemented, + randomCubes: XCTUnimplemented("\(Self.self).randomCubes", placeholder: .mock), + router: .unimplemented, + snsClient: .unimplemented ) } #endif diff --git a/Sources/SnsClient/Mocks.swift b/Sources/SnsClient/Mocks.swift index 3bec82f4..c15d3b74 100644 --- a/Sources/SnsClient/Mocks.swift +++ b/Sources/SnsClient/Mocks.swift @@ -3,15 +3,15 @@ import XCTestDynamicOverlay #if DEBUG extension SnsClient { - public static let failing = Self( + public static let unimplemented = Self( createPlatformEndpoint: { _ in - .failing("\(Self.self).createPlatformEndpoint is not implemented.") + .unimplemented("\(Self.self).createPlatformEndpoint is not implemented.") }, deleteEndpoint: { _ in - .failing("(Self.self).deleteEndpoint is not implemented.") + .unimplemented("(Self.self).deleteEndpoint is not implemented.") }, publish: { _, _ in - .failing("(Self.self).publish is not implemented.") + .unimplemented("(Self.self).publish is not implemented.") } ) } diff --git a/Sources/SoloFeature/SoloView.swift b/Sources/SoloFeature/SoloView.swift index ff26bcae..1710887e 100644 --- a/Sources/SoloFeature/SoloView.swift +++ b/Sources/SoloFeature/SoloView.swift @@ -18,8 +18,8 @@ public struct SoloState: Equatable { public enum SoloAction: Equatable { case gameButtonTapped(GameMode) - case onAppear - case savedGamesLoaded(Result) + case savedGamesLoaded(TaskResult) + case task } public struct SoloEnvironment { @@ -38,16 +38,17 @@ public let soloReducer = Reducer { case .gameButtonTapped: return .none - case .onAppear: - return environment.fileClient.loadSavedGames() - .map(SoloAction.savedGamesLoaded) - case .savedGamesLoaded(.failure): return .none case let .savedGamesLoaded(.success(savedGameState)): state.inProgressGame = savedGameState.unlimited return .none + + case .task: + return .task { + await .savedGamesLoaded(TaskResult { try await environment.fileClient.loadSavedGames() }) + } } } @@ -115,7 +116,7 @@ public struct SoloView: View { } .adaptivePadding([.vertical]) .screenEdgePadding(.horizontal) - .onAppear { viewStore.send(.onAppear) } + .task { await viewStore.send(.task).finish() } } .navigationStyle( backgroundColor: self.colorScheme == .dark ? .isowordsBlack : .solo, diff --git a/Sources/StatsFeature/StatsFeature.swift b/Sources/StatsFeature/StatsFeature.swift index 2b692cf3..77170056 100644 --- a/Sources/StatsFeature/StatsFeature.swift +++ b/Sources/StatsFeature/StatsFeature.swift @@ -65,9 +65,9 @@ public struct StatsState: Equatable { public enum StatsAction: Equatable { case backButtonTapped - case onAppear case setNavigation(tag: StatsState.Route.Tag?) - case statsResponse(Result) + case statsResponse(TaskResult) + case task case vocab(VocabAction) } @@ -114,12 +114,6 @@ public let statsReducer: Reducer = .c case .backButtonTapped: return .none - case .onAppear: - // TODO: should we do this work on background thread? - return environment.database.fetchStats - .mapError { $0 as NSError } - .catchToEffect(StatsAction.statsResponse) - case let .statsResponse(.failure(error)): // TODO return .none @@ -148,6 +142,11 @@ public let statsReducer: Reducer = .c state.route = nil return .none + case .task: + return .task { + await .statsResponse(TaskResult { try await environment.database.fetchStats() }) + } + case .vocab: return .none } @@ -273,7 +272,7 @@ public struct StatsView: View { .adaptiveFont(.matterMedium, size: 16) } } - .onAppear { self.viewStore.send(.onAppear) } + .task { await self.viewStore.send(.task).finish() } .navigationStyle(title: Text("Stats")) } } diff --git a/Sources/SwiftUIHelpers/ActivityView.swift b/Sources/SwiftUIHelpers/ActivityView.swift index b1018e66..d61e6339 100644 --- a/Sources/SwiftUIHelpers/ActivityView.swift +++ b/Sources/SwiftUIHelpers/ActivityView.swift @@ -15,7 +15,7 @@ public struct ActivityView: UIViewControllerRepresentable { return controller } - public func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) - { - } + public func updateUIViewController( + _ uiViewController: UIActivityViewController, context: Context + ) {} } diff --git a/Sources/TcaHelpers/Isolated.swift b/Sources/TcaHelpers/Isolated.swift new file mode 100644 index 00000000..58936325 --- /dev/null +++ b/Sources/TcaHelpers/Isolated.swift @@ -0,0 +1,87 @@ +import Foundation + +@dynamicMemberLookup +@propertyWrapper +public final class Isolated: @unchecked Sendable { + private var _value: Value { + willSet { + self.lock.lock() + defer { self.lock.unlock() } + self.willSet(self._value, newValue) + } + didSet { + self.lock.lock() + defer { self.lock.unlock() } + self.didSet(oldValue, self._value) + } + } + private let lock = NSRecursiveLock() + let willSet: @Sendable (Value, Value) -> Void + let didSet: @Sendable (Value, Value) -> Void + + // TODO: Make configurable with `willSet`, `didSet`, etc.? + public init( + _ value: Value, + willSet: @escaping @Sendable (Value, Value) -> Void = { _, _ in }, + didSet: @escaping @Sendable (Value, Value) -> Void = { _, _ in } + ) { + self._value = value + self.willSet = willSet + self.didSet = didSet + } + + public convenience init(wrappedValue: Value) { + self.init(wrappedValue) + } + + public var value: Value { + _read { + self.lock.lock() + defer { self.lock.unlock() } + yield self._value + } + _modify { + self.lock.lock() + defer { self.lock.unlock() } + yield &self._value + } + } + + public var wrappedValue: Value { + _read { + self.lock.lock() + defer { self.lock.unlock() } + yield self._value + } + _modify { + self.lock.lock() + defer { self.lock.unlock() } + yield &self._value + } + } + + public var projectedValue: Isolated { + self + } + + public subscript(dynamicMember keyPath: WritableKeyPath) -> Subject { + _read { + self.lock.lock() + defer { self.lock.unlock() } + yield self._value[keyPath: keyPath] + } + _modify { + self.lock.lock() + defer { self.lock.unlock() } + yield &self._value[keyPath: keyPath] + } + } + + public func withExclusiveAccess( + _ operation: @Sendable (inout Value) throws -> T + ) rethrows -> T { + self.lock.lock() + defer { self.lock.unlock() } + return try operation(&self._value) + } +} diff --git a/Sources/TcaHelpers/OptionalPaths.swift b/Sources/TcaHelpers/OptionalPaths.swift index 4a42fe68..4cd9c1ac 100644 --- a/Sources/TcaHelpers/OptionalPaths.swift +++ b/Sources/TcaHelpers/OptionalPaths.swift @@ -182,33 +182,32 @@ extension Reducer { guard var localState = toLocalState.extract(from: globalState) else { #if DEBUG - if breakpointOnNil { - fputs( - """ - --- - Warning: Reducer._pullback@\(file):\(line) - - "\(globalAction)" was received by an optional reducer when its state was \ - "nil". This can happen for a few reasons: - - * The optional reducer was combined with or run from another reducer that set \ - "\(State.self)" to "nil" before the optional reducer ran. Combine or run optional \ - reducers before reducers that can set their state to "nil". This ensures that \ - optional reducers can handle their actions while their state is still non-"nil". - - * An active effect emitted this action while state was "nil". Make sure that effects - for this optional reducer are canceled when optional state is set to "nil". - - * This action was sent to the store while state was "nil". Make sure that actions \ - for this reducer can only be sent to a view store when state is non-"nil". In \ - SwiftUI applications, use "IfLetStore". - --- - - """, - stderr - ) - raise(SIGTRAP) - } + runtimeWarning( + """ + Warning: Reducer._pullback@%@:%d + + "%@" was received by an optional reducer when its state was "nil". This can happen for \ + a few reasons: + + * The optional reducer was combined with or run from another reducer that set "%@" to \ + "nil" before the optional reducer ran. Combine or run optional reducers before \ + reducers that can set their state to "nil". This ensures that optional reducers can \ + handle their actions while their state is still non-"nil". + + * An active effect emitted this action while state was "nil". Make sure that effects + for this optional reducer are canceled when optional state is set to "nil". + + * This action was sent to the store while state was "nil". Make sure that actions \ + for this reducer can only be sent to a view store when state is non-"nil". In \ + SwiftUI applications, use "IfLetStore". + """, + [ + "\(file)", + line, + "\(globalAction)", + "\(State.self)" + ] + ) #endif return .none } diff --git a/Sources/TcaHelpers/RuntimeWarnings.swift b/Sources/TcaHelpers/RuntimeWarnings.swift new file mode 100644 index 00000000..e334f707 --- /dev/null +++ b/Sources/TcaHelpers/RuntimeWarnings.swift @@ -0,0 +1,43 @@ +#if DEBUG && canImport(os) + import os + import XCTestDynamicOverlay + + // NB: Xcode runtime warnings offer a much better experience than traditional assertions and + // breakpoints, but Apple provides no means of creating custom runtime warnings ourselves. + // To work around this, we hook into SwiftUI's runtime issue delivery mechanism, instead. + // + // Feedback filed: https://gist.github.com/stephencelis/a8d06383ed6ccde3e5ef5d1b3ad52bbc + private let rw = ( + dso: { () -> UnsafeMutableRawPointer in + let count = _dyld_image_count() + for i in 0.. StaticString, + _ args: @autoclosure () -> [CVarArg] = [] +) { + #if DEBUG && canImport(os) + if !_XCTIsTesting { + unsafeBitCast( + os_log as (OSLogType, UnsafeRawPointer, OSLog, StaticString, CVarArg...) -> Void, + to: ((OSLogType, UnsafeRawPointer, OSLog, StaticString, [CVarArg]) -> Void).self + )(.fault, rw.dso, rw.log, message(), args()) + } + #endif +} diff --git a/Sources/TcaHelpers/Send.swift b/Sources/TcaHelpers/Send.swift new file mode 100644 index 00000000..a9937ae4 --- /dev/null +++ b/Sources/TcaHelpers/Send.swift @@ -0,0 +1,19 @@ +import ComposableArchitecture +import UIKit + +extension Send { + public func callAsFunction( + _ action: Action, + animateWithDuration duration: TimeInterval, + delay: TimeInterval = 0, + options animationOptions: UIView.AnimationOptions = [] + ) { + guard !Task.isCancelled else { return } + UIView.animate( + withDuration: duration, + delay: delay, + options: animationOptions, + animations: { self.send(action) } + ) + } +} diff --git a/Sources/TestHelpers/AsyncStreamProducer.swift b/Sources/TestHelpers/AsyncStreamProducer.swift new file mode 100644 index 00000000..245683cc --- /dev/null +++ b/Sources/TestHelpers/AsyncStreamProducer.swift @@ -0,0 +1,31 @@ +public final class AsyncStreamProducer { + public private(set) var continuation = Continuation() + + public init() {} + + public var stream: AsyncStream { + AsyncStream { self.continuation.continuations.append($0) } + } + + public struct Continuation { + fileprivate var continuations: [AsyncStream.Continuation] = [] + + public func yield(_ value: Element) { + for continuation in self.continuations { + continuation.yield(value) + } + } + + public func yield(with result: Result) { + for continuation in self.continuations { + continuation.yield(with: result) + } + } + + public func finish() { + for continuation in self.continuations { + continuation.finish() + } + } + } +} diff --git a/Sources/TrailerFeature/Trailer.swift b/Sources/TrailerFeature/Trailer.swift index 9b8815a8..75fdc122 100644 --- a/Sources/TrailerFeature/Trailer.swift +++ b/Sources/TrailerFeature/Trailer.swift @@ -7,6 +7,7 @@ import DictionaryClient import GameCore import SharedModels import SwiftUI +import TcaHelpers public struct TrailerState: Equatable { var game: GameState @@ -38,10 +39,9 @@ public struct TrailerState: Equatable { } public enum TrailerAction: BindableAction, Equatable { - case delayedOnAppear - case game(GameAction) case binding(BindingAction) - case onAppear + case game(GameAction) + case task } public struct TrailerEnvironment { @@ -89,7 +89,7 @@ public let trailerReducer = Reducer] = [ - environment.audioPlayer.play(.onboardingBgMusic) - .fireAndForget() - ] - - // Play each word - for (wordIndex, word) in replayableWords.enumerated() { - // Play each character in the word - for (characterIndex, character) in word.enumerated() { - let face = IndexedCubeFace(index: character.index, side: character.side) - - // Move the nub to the face being played - effects.append( - Effect(value: .set(\.$nub.location, .face(face))) - .delay( - for: moveNubDelay(wordIndex: wordIndex, characterIndex: characterIndex), - scheduler: environment.mainQueue - .animate(withDuration: moveNubToFaceDuration, options: .curveEaseInOut) - ) - .eraseToEffect() - ) - effects.append( - Effect.merge( - // Press the nub on the first character - characterIndex == 0 ? Effect(value: .set(\.$nub.isPressed, true)) : .none, - // Tap on each face in the word being played - Effect(value: .game(.tap(.began, face))) - ) - .delay( - for: .seconds( - characterIndex == 0 - ? moveNubToFaceDuration - : .random(in: (0.3 * moveNubToFaceDuration)...(0.7 * moveNubToFaceDuration)) - ), - scheduler: environment.mainQueue.animation() - ) - .eraseToEffect() - ) - } + case .game: + return .none - // Release the nub when the last character is played - effects.append( - Effect(value: .set(\.$nub.isPressed, false)) - .receive(on: environment.mainQueue.animate(withDuration: 0.3)) - .eraseToEffect() - ) - // Move the nub to the submit button - effects.append( - Effect(value: .set(\.$nub.location, .submitButton)) - .delay( - for: 0.2, - scheduler: environment.mainQueue - .animate(withDuration: moveNubToSubmitButtonDuration, options: .curveEaseInOut) + case .task: + return .run { send in + await environment.audioPlayer.load(AudioPlayerClient.Sound.allCases) + + // Play trailer music + await environment.audioPlayer.play(.onboardingBgMusic) + + // Fade the cube in after a second + await send(.set(\.$opacity, 1), animation: .easeInOut(duration: fadeInDuration)) + try await environment.mainQueue.sleep(for: firstWordDelay) + + // Play each word + for (wordIndex, word) in replayableWords.enumerated() { + // Play each character in the word + for (characterIndex, character) in word.enumerated() { + let face = IndexedCubeFace(index: character.index, side: character.side) + + // Move the nub to the face being played + await send( + .set(\.$nub.location, .face(face)), + animateWithDuration: moveNubToFaceDuration, + options: .curveEaseInOut ) - .eraseToEffect() - ) - // Press the nub - effects.append( - Effect(value: .set(\.$nub.isPressed, true)) - .delay( - for: .seconds( - .random( - in: - moveNubToSubmitButtonDuration...(moveNubToSubmitButtonDuration - + submitHestitationDuration) - ) - ), - scheduler: environment.mainQueue.animation() + try await environment.mainQueue.sleep( + for: moveNubDelay(characterIndex: characterIndex) ) - .eraseToEffect() - ) - // Submit the word - effects.append( - Effect(value: .game(.submitButtonTapped(reaction: nil))) - ) - // Release the nub - effects.append( - Effect(value: .set(\.$nub.isPressed, false)) - .delay( - for: .seconds(submitPressDuration), - scheduler: environment.mainQueue.animate(withDuration: 0.3) + + try await environment.mainQueue.sleep( + for: .seconds(.random(in: (0.3*moveNubToFaceDuration)...(0.7*moveNubToFaceDuration))) ) - .eraseToEffect() - ) - } + // Press the nub on the first character + if characterIndex == 0 { + await send(.set(\.$nub.isPressed, true), animateWithDuration: 0.3) + } + // Select the cube face + await send(.game(.tap(.began, face)), animation: .default) + } + + // Release the nub when the last character is played + await send(.set(\.$nub.isPressed, false), animateWithDuration: 0.3) - // Move the nub off screen once all words have been played - effects.append( - Effect(value: .set(\.$nub.location, .offScreenBottom)) - .delay(for: .seconds(0.3), scheduler: environment.mainQueue) - .receive( - on: environment.mainQueue - .animate(withDuration: moveNubOffScreenDuration, options: .curveEaseInOut) + // Move the nub to the submit button + try await environment.mainQueue.sleep(for: .seconds(0.3)) + await send( + .set(\.$nub.location, .submitButton), + animateWithDuration: moveNubToSubmitButtonDuration, + options: .curveEaseInOut ) - .eraseToEffect() - ) - // Fade the scene out - effects.append( - Effect(value: .set(\.$opacity, 0)) - .receive(on: environment.mainQueue.animation(.linear(duration: moveNubOffScreenDuration))) - .eraseToEffect() - ) - return .concatenate(effects) + // Press the nub + try await environment.mainQueue.sleep( + for: .seconds( + .random( + in: moveNubToSubmitButtonDuration ... + (moveNubToSubmitButtonDuration + submitHestitationDuration) + ) + ) + ) - case .game: - return .none + // Submit the word + try await environment.mainQueue.sleep(for: .seconds(0.1)) + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await send(.set(\.$nub.isPressed, true), animateWithDuration: 0.3) + } + group.addTask { + try await environment.mainQueue.sleep(for: .seconds(0.2)) + await send(.game(.submitButtonTapped(reaction: nil))) + try await environment.mainQueue.sleep(for: .seconds(0.3)) + await send(.set(\.$nub.isPressed, false), animateWithDuration: 0.3 ) + } + } + } - case .onAppear: - return .merge( - environment.audioPlayer.load(AudioPlayerClient.Sound.allCases) - .fireAndForget(), + // Move the nub off screen once all words have been played + try await environment.mainQueue.sleep(for: .seconds(0.3)) + await send( + .set(\.$nub.location, .offScreenBottom), + animateWithDuration: moveNubOffScreenDuration, + options: .curveEaseInOut + ) - Effect(value: .delayedOnAppear) - .delay( - for: 1, - scheduler: environment.mainQueue.animation(.easeInOut(duration: fadeInDuration)) - ) - .eraseToEffect() - ) + await send(.set(\.$opacity, 0), animation: .linear(duration: moveNubOffScreenDuration)) + } } } .binding() @@ -344,7 +308,7 @@ public struct TrailerView: View { .grid(15) ) .opacity(self.viewStore.opacity) - .onAppear { self.viewStore.send(.onAppear) } + .task { await self.viewStore.send(.task).finish() } } var scoreText: Text { @@ -354,13 +318,8 @@ public struct TrailerView: View { } } -private func moveNubDelay( - wordIndex: Int, - characterIndex: Int -) -> DispatchQueue.SchedulerTimeType.Stride { - if wordIndex == 0 && characterIndex == 0 { - return firstWordDelay - } else if characterIndex == 0 { +private func moveNubDelay(characterIndex: Int) -> DispatchQueue.SchedulerTimeType.Stride { + if characterIndex == 0 { return firstCharacterDelay } else { return 0 diff --git a/Sources/UIApplicationClient/Client.swift b/Sources/UIApplicationClient/Client.swift index 3c472b07..63de3860 100644 --- a/Sources/UIApplicationClient/Client.swift +++ b/Sources/UIApplicationClient/Client.swift @@ -2,9 +2,13 @@ import ComposableArchitecture import UIKit public struct UIApplicationClient { + // TODO: Should these endpoints be merged and `@MainActor`? Should `Reducer` be `@MainActor`? public var alternateIconName: () -> String? - public var open: (URL, [UIApplication.OpenExternalURLOptionsKey: Any]) -> Effect - public var openSettingsURLString: () -> String - public var setAlternateIconName: (String?) -> Effect - public var supportsAlternateIcons: () -> Bool + public var alternateIconNameAsync: @Sendable () async -> String? + public var open: @Sendable (URL, [UIApplication.OpenExternalURLOptionsKey: Any]) async -> Bool + public var openSettingsURLString: @Sendable () async -> String + public var setAlternateIconName: @Sendable (String?) async throws -> Void + // TODO: Should these endpoints be merged and `@MainActor`? Should `Reducer` be `@MainActor`? + @available(*, deprecated) public var supportsAlternateIcons: () -> Bool + public var supportsAlternateIconsAsync: @Sendable () async -> Bool } diff --git a/Sources/UIApplicationClient/Live.swift b/Sources/UIApplicationClient/Live.swift index 76bf3186..b34f9319 100644 --- a/Sources/UIApplicationClient/Live.swift +++ b/Sources/UIApplicationClient/Live.swift @@ -5,26 +5,11 @@ import UIKit extension UIApplicationClient { public static let live = Self( alternateIconName: { UIApplication.shared.alternateIconName }, - open: { url, options in - .future { callback in - UIApplication.shared.open(url, options: options) { bool in - callback(.success(bool)) - } - } - }, - openSettingsURLString: { UIApplication.openSettingsURLString }, - setAlternateIconName: { iconName in - .run { subscriber in - UIApplication.shared.setAlternateIconName(iconName) { error in - if let error = error { - subscriber.send(completion: .failure(error)) - } else { - subscriber.send(completion: .finished) - } - } - return AnyCancellable {} - } - }, - supportsAlternateIcons: { UIApplication.shared.supportsAlternateIcons } + alternateIconNameAsync: { await UIApplication.shared.alternateIconName }, + open: { @MainActor in await UIApplication.shared.open($0, options: $1) }, + openSettingsURLString: { await UIApplication.openSettingsURLString }, + setAlternateIconName: { @MainActor in try await UIApplication.shared.setAlternateIconName($0) }, + supportsAlternateIcons: { UIApplication.shared.supportsAlternateIcons }, + supportsAlternateIconsAsync: { await UIApplication.shared.supportsAlternateIcons } ) } diff --git a/Sources/UIApplicationClient/Mocks.swift b/Sources/UIApplicationClient/Mocks.swift index 05c217c7..56536934 100644 --- a/Sources/UIApplicationClient/Mocks.swift +++ b/Sources/UIApplicationClient/Mocks.swift @@ -3,29 +3,28 @@ import XCTestDynamicOverlay extension UIApplicationClient { #if DEBUG - public static let failing = Self( - alternateIconName: { - XCTFail("\(Self.self).alternateIconName is unimplemented") - return nil - }, - open: { _, _ in .failing("\(Self.self).open is unimplemented") }, - openSettingsURLString: { - XCTFail("\(Self.self).openSettingsURLString is unimplemented") - return "" - }, - setAlternateIconName: { _ in .failing("\(Self.self).setAlternateIconName is unimplemented") }, - supportsAlternateIcons: { - XCTFail("\(Self.self).supportsAlternateIcons is unimplemented") - return false - } + public static let unimplemented = Self( + alternateIconName: XCTUnimplemented("\(Self.self).alternateIconName"), + alternateIconNameAsync: XCTUnimplemented("\(Self.self).alternateIconNameAsync"), + open: XCTUnimplemented("\(Self.self).open", placeholder: false), + openSettingsURLString: XCTUnimplemented("\(Self.self).openSettingsURLString"), + setAlternateIconName: XCTUnimplemented("\(Self.self).setAlternateIconName"), + supportsAlternateIcons: XCTUnimplemented( + "\(Self.self).supportsAlternateIcons", placeholder: false + ), + supportsAlternateIconsAsync: XCTUnimplemented( + "\(Self.self).setAlternateIconNameAsync", placeholder: false + ) ) #endif public static let noop = Self( alternateIconName: { nil }, - open: { _, _ in .none }, + alternateIconNameAsync: { nil }, + open: { _, _ in false}, openSettingsURLString: { "settings://isowords/settings" }, - setAlternateIconName: { _ in .none }, - supportsAlternateIcons: { true } + setAlternateIconName: { _ in }, + supportsAlternateIcons: { true }, + supportsAlternateIconsAsync: { true } ) } diff --git a/Sources/UpgradeInterstitialFeature/UpgradeInterstitialView.swift b/Sources/UpgradeInterstitialFeature/UpgradeInterstitialView.swift index 467672bc..3b478aa0 100644 --- a/Sources/UpgradeInterstitialFeature/UpgradeInterstitialView.swift +++ b/Sources/UpgradeInterstitialFeature/UpgradeInterstitialView.swift @@ -42,8 +42,8 @@ public enum UpgradeInterstitialAction: Equatable { case delegate(DelegateAction) case fullGameProductResponse(StoreKitClient.Product) case maybeLaterButtonTapped - case onAppear case paymentTransaction(StoreKitClient.PaymentTransactionObserverEvent) + case task case timerTick case upgradeButtonTapped @@ -73,8 +73,7 @@ public let upgradeInterstitialReducer = Reducer< UpgradeInterstitialState, UpgradeInterstitialAction, UpgradeInterstitialEnvironment > { state, action, environment in - struct StoreKitObserverId: Hashable {} - struct TimerId: Hashable {} + enum TimerID {} switch action { case .delegate: @@ -85,13 +84,8 @@ public let upgradeInterstitialReducer = Reducer< return .none case .maybeLaterButtonTapped: - return .merge( - .cancel(id: StoreKitObserverId()), - .cancel(id: TimerId()), - Effect(value: .delegate(.close)) - .receive(on: ImmediateScheduler.shared.animation()) - .eraseToEffect() - ) + return .task { .delegate(.close) } + .animation() case let .paymentTransaction(event): switch event { @@ -101,66 +95,67 @@ public let upgradeInterstitialReducer = Reducer< break case .restoreCompletedTransactionsFinished: state.isPurchasing = false - case .updatedTransactions: - break + case let .updatedTransactions(transactions): + if transactions.contains(where: { $0.error != nil }) { + state.isPurchasing = false + } } - return event.isFullGamePurchased( + guard event.isFullGamePurchased( identifier: environment.serverConfig.config().productIdentifiers.fullGame ) - ? .merge( - .cancel(id: StoreKitObserverId()), - .cancel(id: TimerId()), - Effect(value: .delegate(.fullGamePurchased)) - ) - : .none + else { return .none } + return .task { .delegate(.fullGamePurchased) } - case .onAppear: + case .task: state.upgradeInterstitialDuration = environment.serverConfig.config().upgradeInterstitial.duration - return .merge( - environment.storeKit.observer - .receive(on: environment.mainRunLoop.animation()) - .map(UpgradeInterstitialAction.paymentTransaction) - .eraseToEffect() - .cancellable(id: StoreKitObserverId()), - - environment.storeKit.fetchProducts([ - environment.serverConfig.config().productIdentifiers.fullGame - ]) - .ignoreFailure() - .compactMap { response in - response.products.first { product in - product.productIdentifier == environment.serverConfig.config().productIdentifiers.fullGame + return .run { [isDismissable = state.isDismissable] send in + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + for await event in environment.storeKit.observer() { + await send(.paymentTransaction(event), animation: .default) + } + } + + group.addTask { + let response = try await environment.storeKit.fetchProducts([ + environment.serverConfig.config().productIdentifiers.fullGame + ]) + guard let product = response.products.first(where: { product in + product.productIdentifier == environment.serverConfig.config().productIdentifiers.fullGame + }) + else { return } + await send(.fullGameProductResponse(product), animation: .default) + } + + if !isDismissable { + group.addTask { + await withTaskCancellation(id: TimerID.self) { + for await _ in environment.mainRunLoop.timer(interval: 1) { + await send(.timerTick, animation: .default) + } + } + } } } - .receive(on: environment.mainRunLoop.animation()) - .map(UpgradeInterstitialAction.fullGameProductResponse) - .eraseToEffect(), - - !state.isDismissable - ? Effect.timer(id: TimerId(), every: 1, on: environment.mainRunLoop.animation()) - .map { _ in UpgradeInterstitialAction.timerTick } - .eraseToEffect() - .cancellable(id: TimerId()) - : .none - ) + } case .timerTick: state.secondsPassedCount += 1 return state.secondsPassedCount == state.upgradeInterstitialDuration - ? .cancel(id: TimerId()) + ? .cancel(id: TimerID.self) : .none case .upgradeButtonTapped: state.isPurchasing = true - - let payment = SKMutablePayment() - payment.productIdentifier = environment.serverConfig.config().productIdentifiers.fullGame - payment.quantity = 1 - return environment.storeKit.addPayment(payment) - .fireAndForget() + return .fireAndForget { + let payment = SKMutablePayment() + payment.productIdentifier = environment.serverConfig.config().productIdentifiers.fullGame + payment.quantity = 1 + await environment.storeKit.addPayment(payment) + } } } @@ -266,7 +261,7 @@ public struct UpgradeInterstitialView: View { } } .adaptivePadding() - .onAppear { viewStore.send(.onAppear) } + .task { await viewStore.send(.task).finish() } .applying { if self.colorScheme == .dark { $0.background( @@ -303,27 +298,7 @@ extension StoreKitClient.PaymentTransactionObserverEvent { } } -extension Effect where Output == Bool, Failure == Error { - public static func showUpgradeInterstitial( - gameContext: GameContext, - isFullGamePurchased: Bool, - serverConfig: ServerConfig, - playedGamesCount: () -> Effect - ) -> Self { - playedGamesCount() - .map { count in - !isFullGamePurchased - && shouldShowInterstitial( - gamePlayedCount: count, - gameContext: gameContext, - serverConfig: serverConfig - ) - } - .eraseToEffect() - } -} - -func shouldShowInterstitial( +public func shouldShowInterstitial( gamePlayedCount: Int, gameContext: GameContext, serverConfig: ServerConfig diff --git a/Sources/UserDefaultsClient/Interface.swift b/Sources/UserDefaultsClient/Interface.swift index 16b1f465..3d538592 100644 --- a/Sources/UserDefaultsClient/Interface.swift +++ b/Sources/UserDefaultsClient/Interface.swift @@ -2,38 +2,36 @@ import ComposableArchitecture import Foundation public struct UserDefaultsClient { - public var boolForKey: (String) -> Bool - public var dataForKey: (String) -> Data? - public var doubleForKey: (String) -> Double - public var integerForKey: (String) -> Int - public var remove: (String) -> Effect - public var setBool: (Bool, String) -> Effect - public var setData: (Data?, String) -> Effect - public var setDouble: (Double, String) -> Effect - public var setInteger: (Int, String) -> Effect + public var boolForKey: @Sendable (String) -> Bool + public var dataForKey: @Sendable (String) -> Data? + public var doubleForKey: @Sendable (String) -> Double + public var integerForKey: @Sendable (String) -> Int + public var remove: @Sendable (String) async -> Void + public var setBool: @Sendable (Bool, String) async -> Void + public var setData: @Sendable (Data?, String) async -> Void + public var setDouble: @Sendable (Double, String) async -> Void + public var setInteger: @Sendable (Int, String) async -> Void public var hasShownFirstLaunchOnboarding: Bool { self.boolForKey(hasShownFirstLaunchOnboardingKey) } - public func setHasShownFirstLaunchOnboarding(_ bool: Bool) -> Effect { - self.setBool(bool, hasShownFirstLaunchOnboardingKey) + public func setHasShownFirstLaunchOnboarding(_ bool: Bool) async { + await self.setBool(bool, hasShownFirstLaunchOnboardingKey) } public var installationTime: Double { self.doubleForKey(installationTimeKey) } - public func setInstallationTime(_ double: Double) -> Effect { - self.setDouble(double, installationTimeKey) + public func setInstallationTime(_ double: Double) async { + await self.setDouble(double, installationTimeKey) } - public func incrementMultiplayerOpensCount() -> Effect { + public func incrementMultiplayerOpensCount() async -> Int { let incremented = self.integerForKey(multiplayerOpensCount) + 1 - return .concatenate( - self.setInteger(incremented, multiplayerOpensCount).fireAndForget(), - .init(value: incremented) - ) + await self.setInteger(incremented, multiplayerOpensCount) + return incremented } } diff --git a/Sources/UserDefaultsClient/Live.swift b/Sources/UserDefaultsClient/Live.swift index 56e690a8..9b8ca58b 100644 --- a/Sources/UserDefaultsClient/Live.swift +++ b/Sources/UserDefaultsClient/Live.swift @@ -2,38 +2,20 @@ import Foundation extension UserDefaultsClient { public static func live( - userDefaults: UserDefaults = UserDefaults(suiteName: "group.isowords")! + userDefaults: @autoclosure @escaping () -> UserDefaults = UserDefaults( + suiteName: "group.isowords" + )! ) -> Self { Self( - boolForKey: userDefaults.bool(forKey:), - dataForKey: userDefaults.data(forKey:), - doubleForKey: userDefaults.double(forKey:), - integerForKey: userDefaults.integer(forKey:), - remove: { key in - .fireAndForget { - userDefaults.removeObject(forKey: key) - } - }, - setBool: { value, key in - .fireAndForget { - userDefaults.set(value, forKey: key) - } - }, - setData: { data, key in - .fireAndForget { - userDefaults.set(data, forKey: key) - } - }, - setDouble: { value, key in - .fireAndForget { - userDefaults.set(value, forKey: key) - } - }, - setInteger: { value, key in - .fireAndForget { - userDefaults.set(value, forKey: key) - } - } + boolForKey: { userDefaults().bool(forKey: $0) }, + dataForKey: { userDefaults().data(forKey: $0) }, + doubleForKey: { userDefaults().double(forKey: $0) }, + integerForKey: { userDefaults().integer(forKey: $0) }, + remove: { userDefaults().removeObject(forKey: $0) }, + setBool: { userDefaults().set($0, forKey: $1) }, + setData: { userDefaults().set($0, forKey: $1) }, + setDouble: { userDefaults().set($0, forKey: $1) }, + setInteger: { userDefaults().set($0, forKey: $1) } ) } } diff --git a/Sources/UserDefaultsClient/Mocks.swift b/Sources/UserDefaultsClient/Mocks.swift index 7502a42b..415ab724 100644 --- a/Sources/UserDefaultsClient/Mocks.swift +++ b/Sources/UserDefaultsClient/Mocks.swift @@ -4,11 +4,11 @@ extension UserDefaultsClient { dataForKey: { _ in nil }, doubleForKey: { _ in 0 }, integerForKey: { _ in 0 }, - remove: { _ in .none }, - setBool: { _, _ in .none }, - setData: { _, _ in .none }, - setDouble: { _, _ in .none }, - setInteger: { _, _ in .none } + remove: { _ in }, + setBool: { _, _ in }, + setData: { _, _ in }, + setDouble: { _, _ in }, + setInteger: { _, _ in } ) } @@ -17,29 +17,16 @@ extension UserDefaultsClient { import XCTestDynamicOverlay extension UserDefaultsClient { - public static let failing = Self( - boolForKey: { - key - in XCTFail("\(Self.self).boolForKey(\(key)) is unimplemented") - return false - }, - dataForKey: { key in - XCTFail("\(Self.self).dataForKey(\(key)) is unimplemented") - return nil - }, - doubleForKey: { key in - XCTFail("\(Self.self).doubleForKey(\(key)) is unimplemented") - return 0 - }, - integerForKey: { key in - XCTFail("\(Self.self).integerForKey(\(key)) is unimplemented") - return 0 - }, - remove: { key in .failing("\(Self.self).remove(\(key)) is unimplemented") }, - setBool: { _, key in .failing("\(Self.self).setBool(\(key), _) is unimplemented") }, - setData: { _, key in .failing("\(Self.self).setData(\(key), _) is unimplemented") }, - setDouble: { _, key in .failing("\(Self.self).setDouble(\(key), _) is unimplemented") }, - setInteger: { _, key in .failing("\(Self.self).setInteger(\(key), _) is unimplemented") } + public static let unimplemented = Self( + boolForKey: XCTUnimplemented("\(Self.self).boolForKey", placeholder: false), + dataForKey: XCTUnimplemented("\(Self.self).dataForKey", placeholder: nil), + doubleForKey: XCTUnimplemented("\(Self.self).doubleForKey", placeholder: 0), + integerForKey: XCTUnimplemented("\(Self.self).integerForKey", placeholder: 0), + remove: XCTUnimplemented("\(Self.self).remove"), + setBool: XCTUnimplemented("\(Self.self).setBool"), + setData: XCTUnimplemented("\(Self.self).setData"), + setDouble: XCTUnimplemented("\(Self.self).setDouble"), + setInteger: XCTUnimplemented("\(Self.self).setInteger") ) public mutating func override(bool: Bool, forKey key: String) { diff --git a/Sources/VerifyReceiptMiddleware/ItunesClient.swift b/Sources/VerifyReceiptMiddleware/ItunesClient.swift index 0445f47f..01fb152a 100644 --- a/Sources/VerifyReceiptMiddleware/ItunesClient.swift +++ b/Sources/VerifyReceiptMiddleware/ItunesClient.swift @@ -59,9 +59,9 @@ extension ItunesClient { #if DEBUG extension ItunesClient { - public static let failing = Self( + public static let unimplemented = Self( verify: { _, _ in - .failing("\(Self.self).verify is unimplemented") + .unimplemented("\(Self.self).verify") } ) } diff --git a/Sources/VocabFeature/Vocab.swift b/Sources/VocabFeature/Vocab.swift index f372dad3..9960e0de 100644 --- a/Sources/VocabFeature/Vocab.swift +++ b/Sources/VocabFeature/Vocab.swift @@ -34,10 +34,10 @@ public struct VocabState: Equatable { public enum VocabAction: Equatable { case dismissCubePreview - case gamesResponse(Result) - case onAppear + case gamesResponse(TaskResult) case preview(CubePreviewAction) - case vocabResponse(Result) + case task + case vocabResponse(TaskResult) case wordTapped(LocalDatabaseClient.Vocab.Word) } @@ -117,14 +117,14 @@ public let vocabReducer = Reducer< ) return .none - case .onAppear: - return environment.database.fetchVocab - .mapError { $0 as NSError } - .catchToEffect(VocabAction.vocabResponse) - case .preview: return .none + case .task: + return .task { + await .vocabResponse(TaskResult { try await environment.database.fetchVocab() }) + } + case let .vocabResponse(.success(vocab)): state.vocab = vocab return .none @@ -133,10 +133,16 @@ public let vocabReducer = Reducer< return .none case let .wordTapped(word): - return environment.database.fetchGamesForWord(word.letters) - .map { .init(games: $0, word: word.letters) } - .mapError { $0 as NSError } - .catchToEffect(VocabAction.gamesResponse) + return .task { + await .gamesResponse( + TaskResult { + .init( + games: try await environment.database.fetchGamesForWord(word.letters), + word: word.letters + ) + } + ) + } } } ) @@ -180,7 +186,7 @@ public struct VocabView: View { } } } - .onAppear { viewStore.send(.onAppear) } + .task { await viewStore.send(.task).finish() } .sheet( isPresented: viewStore.binding( get: { $0.cubePreview != nil }, diff --git a/Tests/AppFeatureTests/Mocks/AppEnvironment.swift b/Tests/AppFeatureTests/Mocks/AppEnvironment.swift index 888e8ff4..c6ef849d 100644 --- a/Tests/AppFeatureTests/Mocks/AppEnvironment.swift +++ b/Tests/AppFeatureTests/Mocks/AppEnvironment.swift @@ -8,28 +8,26 @@ import SettingsFeature import UserDefaultsClient extension AppEnvironment { - static let didFinishLaunching = update(failing) { - $0.audioPlayer.load = { _ in .none } + static let didFinishLaunching = update(unimplemented) { + $0.audioPlayer.load = { _ in } $0.backgroundQueue = .immediate - $0.database.migrate = .none + $0.database.migrate = {} $0.dictionary.load = { _ in false } - let fileClient = $0.fileClient - $0.fileClient.load = { - [savedGamesFileName, userSettingsFileName].contains($0) ? .none : fileClient.load($0) - } - $0.fileClient.override(load: userSettingsFileName, Effect.none) - $0.gameCenter.localPlayer.authenticate = .none + $0.fileClient.load = { @Sendable _ in try await Task.never() } + $0.gameCenter.localPlayer.authenticate = {} + $0.gameCenter.localPlayer.listener = { .finished } $0.mainQueue = .immediate $0.mainRunLoop = .immediate - $0.serverConfig.refresh = { .none } - $0.storeKit.observer = .none + $0.serverConfig.refresh = { .init() } + $0.storeKit.observer = { .finished } $0.userDefaults.override(bool: true, forKey: "hasShownFirstLaunchOnboardingKey") $0.userDefaults.override(double: 0, forKey: "installationTimeKey") let defaults = $0.userDefaults - $0.userDefaults.setDouble = { - $1 == "installationTimeKey" ? .none : defaults.setDouble($0, $1) + $0.userDefaults.setDouble = { _, _ in } + $0.userNotifications.delegate = { .finished } + $0.userNotifications.getNotificationSettings = { + (try? await Task.never()) ?? .init(authorizationStatus: .notDetermined) } - $0.userNotifications.delegate = .none - $0.userNotifications.getNotificationSettings = .none + $0.userNotifications.requestAuthorization = { _ in false } } } diff --git a/Tests/AppFeatureTests/PersistenceTests.swift b/Tests/AppFeatureTests/PersistenceTests.swift index 472a13eb..a26139fa 100644 --- a/Tests/AppFeatureTests/PersistenceTests.swift +++ b/Tests/AppFeatureTests/PersistenceTests.swift @@ -21,26 +21,24 @@ import XCTest @testable import SoloFeature @testable import UserDefaultsClient +@MainActor class PersistenceTests: XCTestCase { - func testUnlimitedSaveAndQuit() { - var saves: [Data] = [] + func testUnlimitedSaveAndQuit() async throws { + let saves = ActorIsolated<[Data]>([]) let store = TestStore( initialState: .init( home: .init(route: .solo(.init())) ), reducer: appReducer, - environment: update(.failing) { - $0.audioPlayer.play = { _ in .none } - $0.audioPlayer.stop = { _ in .none } + environment: update(.unimplemented) { + $0.audioPlayer.play = { _ in } + $0.audioPlayer.stop = { _ in } $0.backgroundQueue = .immediate $0.dictionary.contains = { word, _ in word == "CAB" } $0.dictionary.randomCubes = { _ in .mock } $0.feedbackGenerator = .noop - $0.fileClient.save = { _, data in - saves.append(data) - return .none - } + $0.fileClient.save = { @Sendable _, data in await saves.withValue { $0.append(data) } } $0.mainRunLoop = .immediate $0.mainQueue = .immediate } @@ -51,7 +49,7 @@ class PersistenceTests: XCTestCase { let A = IndexedCubeFace(index: index, side: .left) let B = IndexedCubeFace(index: index, side: .right) - store.send(.home(.solo(.gameButtonTapped(.unlimited)))) { + await store.send(.home(.solo(.gameButtonTapped(.unlimited)))) { $0.game = GameState( cubes: .mock, gameContext: .solo, @@ -61,41 +59,41 @@ class PersistenceTests: XCTestCase { ) $0.home.savedGames.unlimited = $0.game.map(InProgressGame.init) } - store.send(.currentGame(.game(.tap(.began, C)))) { + await store.send(.currentGame(.game(.tap(.began, C)))) { try XCTUnwrap(&$0.game) { $0.optimisticallySelectedFace = C $0.selectedWord = [C] } } - store.send(.currentGame(.game(.tap(.ended, C)))) { + await store.send(.currentGame(.game(.tap(.ended, C)))) { try XCTUnwrap(&$0.game) { $0.optimisticallySelectedFace = nil } } - store.send(.currentGame(.game(.tap(.began, A)))) { + await store.send(.currentGame(.game(.tap(.began, A)))) { try XCTUnwrap(&$0.game) { $0.optimisticallySelectedFace = A $0.selectedWord = [C, A] } } - store.send(.currentGame(.game(.tap(.ended, A)))) { + await store.send(.currentGame(.game(.tap(.ended, A)))) { try XCTUnwrap(&$0.game) { $0.optimisticallySelectedFace = nil } } - store.send(.currentGame(.game(.tap(.began, B)))) { + await store.send(.currentGame(.game(.tap(.began, B)))) { try XCTUnwrap(&$0.game) { $0.optimisticallySelectedFace = B $0.selectedWord = [C, A, B] $0.selectedWordIsValid = true } } - store.send(.currentGame(.game(.tap(.ended, B)))) { + await store.send(.currentGame(.game(.tap(.ended, B)))) { try XCTUnwrap(&$0.game) { $0.optimisticallySelectedFace = nil } } - store.send(.currentGame(.game(.submitButtonTapped(reaction: nil)))) { + await store.send(.currentGame(.game(.submitButtonTapped(reaction: nil)))) { try XCTUnwrap(&$0.game) { $0.moves = [ .init( @@ -114,7 +112,7 @@ class PersistenceTests: XCTestCase { } $0.home.savedGames.unlimited = $0.game.map(InProgressGame.init) } - store.send(.currentGame(.game(.menuButtonTapped))) { + await store.send(.currentGame(.game(.menuButtonTapped))) { try XCTUnwrap(&$0.game) { $0.bottomMenu = .init( title: .init("Solo"), @@ -140,19 +138,21 @@ class PersistenceTests: XCTestCase { ) } } - store.send(.currentGame(.game(.exitButtonTapped))) { appState in + await store.send(.currentGame(.game(.exitButtonTapped))) { appState in try XCTUnwrap(&appState.game) { game in appState.home.savedGames.unlimited = InProgressGame(gameState: game) } appState.game = nil - XCTAssertNoDifference(2, saves.count) - XCTAssertNoDifference(saves.last!, try JSONEncoder().encode(appState.home.savedGames)) + } + try await saves.withValue { + XCTAssertNoDifference(2, $0.count) + XCTAssertNoDifference($0.last, try JSONEncoder().encode(store.state.home.savedGames)) } } - func testUnlimitedAbandon() throws { - var didArchiveGame = false - var saves: [Data] = [] + func testUnlimitedAbandon() async throws { + let didArchiveGame = ActorIsolated(false) + let saves = ActorIsolated<[Data]>([]) let store = TestStore( initialState: AppState( @@ -160,23 +160,17 @@ class PersistenceTests: XCTestCase { home: HomeState(savedGames: SavedGamesState(unlimited: .mock)) ), reducer: appReducer, - environment: update(.failing) { - $0.audioPlayer.stop = { _ in .none } + environment: update(.unimplemented) { + $0.audioPlayer.stop = { _ in } $0.backgroundQueue = .immediate - $0.database.saveGame = { _ in - didArchiveGame = true - return .none - } + $0.database.saveGame = { _ in await didArchiveGame.setValue(true) } $0.gameCenter.localPlayer.localPlayer = { .notAuthenticated } - $0.fileClient.save = { _, data in - saves.append(data) - return .none - } + $0.fileClient.save = { @Sendable _, data in await saves.withValue { $0.append(data) } } $0.mainQueue = .immediate } ) - store.send(.currentGame(.game(.menuButtonTapped))) { + await store.send(.currentGame(.game(.menuButtonTapped))) { try XCTUnwrap(&$0.game) { $0.bottomMenu = .init( title: .init("Solo"), @@ -202,7 +196,7 @@ class PersistenceTests: XCTestCase { ) } } - store.send(.currentGame(.game(.endGameButtonTapped))) { + await store.send(.currentGame(.game(.endGameButtonTapped))) { try XCTUnwrap(&$0.game) { $0.gameOver = GameOverState( completedGame: .init(gameState: $0), @@ -213,27 +207,26 @@ class PersistenceTests: XCTestCase { $0.home.savedGames.unlimited = nil } - XCTAssertNoDifference(didArchiveGame, true) - XCTAssertNoDifference(saves, [try JSONEncoder().encode(SavedGamesState())]) + await didArchiveGame.withValue { XCTAssert($0) } + try await saves.withValue { + XCTAssertNoDifference($0, [try JSONEncoder().encode(SavedGamesState())]) + } } - func testTimedAbandon() { - var didArchiveGame = false + func testTimedAbandon() async { + let didArchiveGame = ActorIsolated(false) let store = TestStore( initialState: AppState(game: update(.mock) { $0.gameMode = .timed }), reducer: appReducer, - environment: update(.failing) { - $0.audioPlayer.stop = { _ in .none } - $0.database.saveGame = { _ in - didArchiveGame = true - return .none - } + environment: update(.unimplemented) { + $0.audioPlayer.stop = { _ in } + $0.database.saveGame = { _ in await didArchiveGame.setValue(true) } $0.mainQueue = .immediate } ) - store.send(.currentGame(.game(.menuButtonTapped))) { + await store.send(.currentGame(.game(.menuButtonTapped))) { try XCTUnwrap(&$0.game) { $0.bottomMenu = .init( title: .init("Solo"), @@ -254,7 +247,7 @@ class PersistenceTests: XCTestCase { ) } } - store.send(.currentGame(.game(.endGameButtonTapped))) { + await store.send(.currentGame(.game(.endGameButtonTapped))) { try XCTUnwrap(&$0.game) { $0.gameOver = GameOverState( completedGame: .init(gameState: $0), @@ -263,33 +256,35 @@ class PersistenceTests: XCTestCase { $0.bottomMenu = nil } } + .finish() - XCTAssertNoDifference(didArchiveGame, true) + await didArchiveGame.withValue { XCTAssert($0) } } - func testUnlimitedResume() { + func testUnlimitedResume() async { let savedGames = SavedGamesState(dailyChallengeUnlimited: nil, unlimited: .mock) let store = TestStore( initialState: AppState(), reducer: appReducer, environment: update(.didFinishLaunching) { - $0.fileClient.override(load: savedGamesFileName, .init(value: savedGames)) + $0.fileClient.override(load: savedGamesFileName, savedGames) } ) - store.send(.appDelegate(.didFinishLaunching)) - store.receive(.savedGamesLoaded(.success(savedGames))) { + let task = await store.send(.appDelegate(.didFinishLaunching)) + await store.receive(.savedGamesLoaded(.success(savedGames))) { $0.home.savedGames = savedGames } - store.send(.home(.setNavigation(tag: .solo))) { + await store.send(.home(.setNavigation(tag: .solo))) { $0.home.route = .solo(.init(inProgressGame: .mock)) } - store.send(.home(.solo(.gameButtonTapped(.unlimited)))) { + await store.send(.home(.solo(.gameButtonTapped(.unlimited)))) { $0.game = GameState(inProgressGame: .mock) } + await task.cancel() } - func testTurnBasedAbandon() { + func testTurnBasedAbandon() async { let store = TestStore( initialState: AppState( game: update(.mock) { @@ -309,12 +304,12 @@ class PersistenceTests: XCTestCase { ) ), reducer: appReducer, - environment: update(.failing) { - $0.audioPlayer.stop = { _ in .none } + environment: update(.unimplemented) { + $0.audioPlayer.stop = { _ in } } ) - store.send(.currentGame(.game(.endGameButtonTapped))) { + await store.send(.currentGame(.game(.endGameButtonTapped))) { try XCTUnwrap(&$0.game) { var gameOver = GameOverState( completedGame: .init(gameState: $0), diff --git a/Tests/AppFeatureTests/RemoteNotificationsTests.swift b/Tests/AppFeatureTests/RemoteNotificationsTests.swift index fdf69c79..12be486b 100644 --- a/Tests/AppFeatureTests/RemoteNotificationsTests.swift +++ b/Tests/AppFeatureTests/RemoteNotificationsTests.swift @@ -9,24 +9,23 @@ import XCTest @testable import AppFeature +@MainActor class RemoteNotificationsTests: XCTestCase { - func testRegisterForRemoteNotifications_OnActivate_Authorized() { - var didRegisterForRemoteNotifications = false - var requestedAuthorizationOptions: UNAuthorizationOptions? + func testRegisterForRemoteNotifications_OnActivate_Authorized() async { + let didRegisterForRemoteNotifications = ActorIsolated(false) + let requestedAuthorizationOptions = ActorIsolated(nil) var environment = AppEnvironment.didFinishLaunching environment.build.number = { 80 } environment.remoteNotifications.register = { - .fireAndForget { - didRegisterForRemoteNotifications = true - } + await didRegisterForRemoteNotifications.setValue(true) + } + environment.userNotifications.getNotificationSettings = { + .init(authorizationStatus: .authorized) } - environment.userNotifications.getNotificationSettings = .init( - value: .init(authorizationStatus: .authorized) - ) environment.userNotifications.requestAuthorization = { options in - requestedAuthorizationOptions = options - return .init(value: true) + await requestedAuthorizationOptions.setValue(options) + return true } let store = TestStore( @@ -37,40 +36,41 @@ class RemoteNotificationsTests: XCTestCase { // Register remote notifications on .didFinishLaunching - store.send(.appDelegate(.didFinishLaunching)) - XCTAssertNoDifference(requestedAuthorizationOptions, [.alert, .sound]) - XCTAssertTrue(didRegisterForRemoteNotifications) + let task = await store.send(.appDelegate(.didFinishLaunching)) + await requestedAuthorizationOptions.withValue { XCTAssertNoDifference($0, [.alert, .sound]) } + await didRegisterForRemoteNotifications.withValue { XCTAssertTrue($0) } store.environment.apiClient.override( route: .push( .register(.init(authorizationStatus: .authorized, build: 80, token: "6465616462656566")) ), - withResponse: .init(value: (Data(), URLResponse())) + withResponse: { (Data(), URLResponse()) } ) - store.send(.appDelegate(.didRegisterForRemoteNotifications(.success(Data("deadbeef".utf8))))) + await store.send( + .appDelegate(.didRegisterForRemoteNotifications(.success(Data("deadbeef".utf8))))) // Register remote notifications on .didChangeScenePhase(.active) - didRegisterForRemoteNotifications = false + await didRegisterForRemoteNotifications.setValue(false) - store.environment.audioPlayer.secondaryAudioShouldBeSilencedHint = { false } - store.environment.audioPlayer.setGlobalVolumeForMusic = { _ in .none } - - store.send(.didChangeScenePhase(.active)) - XCTAssertTrue(didRegisterForRemoteNotifications) + await store.send(.didChangeScenePhase(.active)) + await didRegisterForRemoteNotifications.withValue { XCTAssertTrue($0) } store.environment.apiClient.override( route: .push( .register(.init(authorizationStatus: .authorized, build: 80, token: "6261616462656566")) ), - withResponse: .init(value: (Data(), URLResponse())) + withResponse: { (Data(), URLResponse()) } ) - store.send(.appDelegate(.didRegisterForRemoteNotifications(.success(Data("baadbeef".utf8))))) + await store.send( + .appDelegate(.didRegisterForRemoteNotifications(.success(Data("baadbeef".utf8))))) + + await task.cancel() } - func testRegisterForRemoteNotifications_NotAuthorized() { + func testRegisterForRemoteNotifications_NotAuthorized() async { var environment = AppEnvironment.didFinishLaunching - environment.remoteNotifications = .failing + environment.remoteNotifications = .unimplemented let store = TestStore( initialState: AppState(), @@ -78,22 +78,17 @@ class RemoteNotificationsTests: XCTestCase { environment: environment ) - store.send(.appDelegate(.didFinishLaunching)) - - store.environment.audioPlayer.secondaryAudioShouldBeSilencedHint = { false } - store.environment.audioPlayer.setGlobalVolumeForMusic = { _ in .none } - - store.send(.didChangeScenePhase(.active)) + let task = await store.send(.appDelegate(.didFinishLaunching)) + await store.send(.didChangeScenePhase(.active)) + await task.cancel() } - func testReceiveNotification_dailyChallengeEndsSoon() { - let userNotificationsDelegate = PassthroughSubject< - UserNotificationClient.DelegateEvent, Never - >() + func testReceiveNotification_dailyChallengeEndsSoon() async { + let delegate = AsyncStream.streamWithContinuation() var environment = AppEnvironment.didFinishLaunching - environment.fileClient.save = { _, _ in .none } - environment.userNotifications.delegate = userNotificationsDelegate.eraseToEffect() + environment.fileClient.save = { @Sendable _, _ in } + environment.userNotifications.delegate = { delegate.stream } let inProgressGame = InProgressGame.mock @@ -125,37 +120,35 @@ class RemoteNotificationsTests: XCTestCase { var didReceiveResponseCompletionHandlerCalled = false let didReceiveResponseCompletionHandler = { didReceiveResponseCompletionHandlerCalled = true } - store.send(.appDelegate(.didFinishLaunching)) + let task = await store.send(.appDelegate(.didFinishLaunching)) - userNotificationsDelegate.send( + delegate.continuation.yield( .willPresentNotification( notification, - completionHandler: willPresentNotificationCompletionHandler + completionHandler: { willPresentNotificationCompletionHandler($0) } ) ) - - store.receive( + await store.receive( .appDelegate( .userNotifications( .willPresentNotification( notification, - completionHandler: willPresentNotificationCompletionHandler + completionHandler: { willPresentNotificationCompletionHandler($0) } ) ) ) ) XCTAssertNoDifference(notificationPresentationOptions, .banner) - userNotificationsDelegate.send( - .didReceiveResponse(response, completionHandler: didReceiveResponseCompletionHandler) + delegate.continuation.yield( + .didReceiveResponse(response, completionHandler: { didReceiveResponseCompletionHandler() }) ) - - store.receive( + await store.receive( .appDelegate( .userNotifications( .didReceiveResponse( response, - completionHandler: didReceiveResponseCompletionHandler + completionHandler: { didReceiveResponseCompletionHandler() } ) ) ) @@ -165,6 +158,6 @@ class RemoteNotificationsTests: XCTestCase { } XCTAssert(didReceiveResponseCompletionHandlerCalled) - userNotificationsDelegate.send(completion: .finished) + await task.cancel() } } diff --git a/Tests/AppFeatureTests/SharedGameTests.swift b/Tests/AppFeatureTests/SharedGameTests.swift index 95a25fa8..3d7c0265 100644 --- a/Tests/AppFeatureTests/SharedGameTests.swift +++ b/Tests/AppFeatureTests/SharedGameTests.swift @@ -53,7 +53,7 @@ // } // ) // -// let environment = update(AppEnvironment.failing) { +// let environment = update(AppEnvironment.unimplemented) { // $0.apiClient.request = { route in // switch route { // case .sharedGame(.fetch): diff --git a/Tests/AppFeatureTests/TurnBasedTests.swift b/Tests/AppFeatureTests/TurnBasedTests.swift index 54b81293..6278fdd2 100644 --- a/Tests/AppFeatureTests/TurnBasedTests.swift +++ b/Tests/AppFeatureTests/TurnBasedTests.swift @@ -20,78 +20,117 @@ import XCTest @testable import ComposableGameCenter @testable import HomeFeature +@MainActor class TurnBasedTests: XCTestCase { let backgroundQueue = DispatchQueue.test let mainQueue = DispatchQueue.test let mainRunLoop = RunLoop.test - func testNewGame() throws { - var didEndTurnWithRequest: TurnBasedMatchClient.EndTurnRequest? - var didSaveCurrentTurn = false - let listener = PassthroughSubject() + func testNewGame() async throws { + let didEndTurnWithRequest = ActorIsolated(nil) + let didSaveCurrentTurn = ActorIsolated(false) + + let listener = AsyncStreamProducer() let newMatch = update(TurnBasedMatch.new) { $0.creationDate = self.mainRunLoop.now.date } let currentPlayer = CurrentPlayerEnvelope.mock + let dailyChallenges = [ + FetchTodaysDailyChallengeResponse( + dailyChallenge: .init( + endsAt: self.mainRunLoop.now.date, + gameMode: .unlimited, + id: .init(rawValue: .dailyChallengeId), + language: .en + ), + yourResult: .init(outOf: 0, rank: nil, score: nil) + ), + FetchTodaysDailyChallengeResponse( + dailyChallenge: .init( + endsAt: self.mainRunLoop.now.date, + gameMode: .timed, + id: .init(rawValue: .dailyChallengeId), + language: .en + ), + yourResult: .init(outOf: 0, rank: nil, score: nil) + ), + ] + let weekInReview = FetchWeekInReviewResponse(ranks: [], word: nil) let store = TestStore( initialState: .init( home: .init(route: .multiplayer(.init(hasPastGames: false))) ), reducer: appReducer, environment: update(.didFinishLaunching) { - $0.apiClient.override(route: .dailyChallenge(.today(language: .en)), withResponse: .none) - $0.apiClient - .override(route: .leaderboard(.weekInReview(language: .en)), withResponse: .none) - $0.apiClient.authenticate = { _ in .init(value: .mock) } + // TODO: asyncOverride + $0.apiClient.apiRequest = { @Sendable route in + switch route { + case .dailyChallenge(.today): + return try (JSONEncoder().encode(dailyChallenges), .init()) + case .leaderboard(.weekInReview): + return try (JSONEncoder().encode(weekInReview), .init()) + default: + return try await Task.never() + } + } + $0.apiClient.authenticate = { _ in .mock } $0.apiClient.currentPlayer = { currentPlayer } - $0.audioPlayer.loop = { _ in .none } - $0.audioPlayer.play = { _ in .none } - $0.audioPlayer.stop = { _ in .none } + $0.audioPlayer.loop = { _ in } + $0.audioPlayer.play = { _ in } + $0.audioPlayer.stop = { _ in } $0.backgroundQueue = self.backgroundQueue.eraseToAnyScheduler() $0.build.number = { 42 } - $0.database.playedGamesCount = { _ in .none } $0.deviceId.id = { .deviceId } $0.dictionary.contains = { word, _ in word == "CAB" } $0.dictionary.randomCubes = { _ in .mock } $0.feedbackGenerator = .noop - $0.gameCenter.localPlayer.authenticate = .init(value: nil) - $0.gameCenter.localPlayer.listener = listener.eraseToEffect() + $0.fileClient.save = { @Sendable _, _ in } + $0.fileClient.load = { @Sendable _ in try await Task.never() } + $0.gameCenter.localPlayer.authenticate = {} + $0.gameCenter.localPlayer.listener = { listener.stream } $0.gameCenter.localPlayer.localPlayer = { .mock } - $0.gameCenter.turnBasedMatch.endTurn = { - didEndTurnWithRequest = $0 - return .none - } - $0.gameCenter.turnBasedMatch.loadMatches = { .init(value: []) } + $0.gameCenter.turnBasedMatch.endTurn = { await didEndTurnWithRequest.setValue($0) } + $0.gameCenter.turnBasedMatch.loadMatches = { [] } $0.gameCenter.turnBasedMatch.saveCurrentTurn = { _, _ in - didSaveCurrentTurn = true - return .none + await didSaveCurrentTurn.setValue(true) } - $0.gameCenter.turnBasedMatchmakerViewController.dismiss = .none - $0.gameCenter.turnBasedMatchmakerViewController.present = { _ in .none } - $0.lowPowerMode.start = .none + $0.gameCenter.turnBasedMatchmakerViewController.dismiss = {} + $0.gameCenter.turnBasedMatchmakerViewController.present = { @Sendable _ in } + $0.lowPowerMode.start = { .never } $0.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler() $0.serverConfig.config = { .init() } - $0.serverConfig.refresh = { .init(value: .init()) } + $0.serverConfig.refresh = { .init() } + $0.userDefaults.setInteger = { _, _ in } $0.timeZone = { .newYork } } ) - store.send(.appDelegate(.didFinishLaunching)) - store.send(.home(.onAppear)) + let didFinishLaunchingTask = await store.send(.appDelegate(.didFinishLaunching)) + let homeTask = await store.send(.home(.task)) - store.receive(.home(.authenticationResponse(.mock))) - store.receive(.home(.serverConfigResponse(.init()))) { + await store.receive(.home(.authenticationResponse(.mock))) + await store.receive(.home(.serverConfigResponse(.init()))) { $0.home.hasChangelog = true } + await store.receive(.home(.dailyChallengeResponse(.success(dailyChallenges)))) { + $0.home.dailyChallenges = dailyChallenges + } + await store.receive(.home(.weekInReviewResponse(.success(weekInReview)))) { + $0.home.weekInReview = weekInReview + } - self.backgroundQueue.advance() - self.mainRunLoop.advance() - store.receive(.home(.set(\.$hasPastTurnBasedGames, false))) - store.receive(.home(.matchesLoaded(.success([])))) + await self.backgroundQueue.advance() + await self.mainRunLoop.advance() + await store.receive( + .home(.activeMatchesResponse(.success(.init(matches: [], hasPastTurnBasedGames: false)))) + ) - store.send(.home(.multiplayer(.startButtonTapped))) + await store.send(.home(.multiplayer(.startButtonTapped))) - listener.send(.turnBased(.receivedTurnEventForMatch(newMatch, didBecomeActive: true))) + listener.continuation + .yield(.turnBased(.receivedTurnEventForMatch(newMatch, didBecomeActive: true))) + await self.backgroundQueue.advance() + await self.mainRunLoop.advance() let initialGameState = GameState( inProgressGame: InProgressGame( @@ -109,7 +148,7 @@ class TurnBasedTests: XCTestCase { secondsPlayed: 0 ) ) - store.receive( + await store.receive( .gameCenter(.listener(.turnBased(.receivedTurnEventForMatch(newMatch, didBecomeActive: true)))) ) { $0.game = initialGameState @@ -123,59 +162,59 @@ class TurnBasedTests: XCTestCase { store.environment.userDefaults.setInteger = { int, key in XCTAssertNoDifference(int, 1) XCTAssertNoDifference(key, "multiplayerOpensCount") - return .none } - store.send(.currentGame(.game(.onAppear))) + await store.receive( + .home(.activeMatchesResponse(.success(.init(matches: [], hasPastTurnBasedGames: false)))) + ) + let gameTask = await store.send(.currentGame(.game(.task))) - store.receive(.currentGame(.game(.gameLoaded))) { + await store.receive(.currentGame(.game(.gameLoaded))) { try XCTUnwrap(&$0.game) { $0.isGameLoaded = true } } - store.receive(.currentGame(.game(.matchesLoaded(.success([]))))) + await store.receive(.currentGame(.game(.matchesLoaded(.success([]))))) - self.backgroundQueue.advance() - self.mainRunLoop.advance() - store.receive(.home(.set(\.$hasPastTurnBasedGames, false))) - store.receive(.home(.matchesLoaded(.success([])))) + await self.backgroundQueue.advance() + await self.mainRunLoop.advance() - XCTAssert(didSaveCurrentTurn) + await didSaveCurrentTurn.withValue { XCTAssert($0) } let index = LatticePoint(x: .two, y: .two, z: .two) let C = IndexedCubeFace(index: index, side: .top) let A = IndexedCubeFace(index: index, side: .left) let B = IndexedCubeFace(index: index, side: .right) - store.send(.currentGame(.game(.tap(.began, C)))) { + await store.send(.currentGame(.game(.tap(.began, C)))) { try XCTUnwrap(&$0.game) { $0.optimisticallySelectedFace = C $0.selectedWord = [C] } } - store.send(.currentGame(.game(.tap(.ended, C)))) { + await store.send(.currentGame(.game(.tap(.ended, C)))) { try XCTUnwrap(&$0.game) { $0.optimisticallySelectedFace = nil } } - store.send(.currentGame(.game(.tap(.began, A)))) { + await store.send(.currentGame(.game(.tap(.began, A)))) { try XCTUnwrap(&$0.game) { $0.optimisticallySelectedFace = A $0.selectedWord = [C, A] } } - store.send(.currentGame(.game(.tap(.ended, A)))) { + await store.send(.currentGame(.game(.tap(.ended, A)))) { try XCTUnwrap(&$0.game) { $0.optimisticallySelectedFace = nil } } - store.send(.currentGame(.game(.tap(.began, B)))) { + await store.send(.currentGame(.game(.tap(.began, B)))) { try XCTUnwrap(&$0.game) { $0.optimisticallySelectedFace = B $0.selectedWord = [C, A, B] $0.selectedWordIsValid = true } } - store.send(.currentGame(.game(.tap(.ended, B)))) { + await store.send(.currentGame(.game(.tap(.ended, B)))) { try XCTUnwrap(&$0.game) { $0.optimisticallySelectedFace = nil } @@ -218,21 +257,20 @@ class TurnBasedTests: XCTestCase { ) ) } - store.environment.gameCenter.turnBasedMatch.load = { _ in - .init(value: updatedMatch) - } + store.environment.gameCenter.turnBasedMatch.load = { _ in updatedMatch } - store.send(.currentGame(.game(.submitButtonTapped(reaction: .angel)))) { + await store.send(.currentGame(.game(.submitButtonTapped(reaction: .angel)))) { $0.game = updatedGameState - + } + try await didEndTurnWithRequest.withValue { XCTAssertNoDifference( - didEndTurnWithRequest, + $0, .init( for: newMatch.matchId, matchData: Data( turnBasedMatchData: TurnBasedMatchData( - context: try XCTUnwrap($0.currentGame.game?.turnBasedContext), - gameState: try XCTUnwrap($0.game), + context: try XCTUnwrap(store.state.currentGame.game?.turnBasedContext), + gameState: try XCTUnwrap(store.state.game), playerId: currentPlayer.player.id ) ), @@ -241,7 +279,7 @@ class TurnBasedTests: XCTestCase { ) } - store.receive( + await store.receive( .currentGame(.game(.gameCenter(.turnBasedMatchResponse(.success(updatedMatch))))) ) { try XCTUnwrap(&$0.game) { @@ -256,45 +294,90 @@ class TurnBasedTests: XCTestCase { } } - store.send(.currentGame(.onDisappear)) - listener.send(completion: .finished) + await gameTask.cancel() + await homeTask.cancel() + await didFinishLaunchingTask.cancel() } - func testResumeGame() { - let listener = PassthroughSubject() + func testResumeGame() async { + let listener = AsyncStreamProducer() + + let dailyChallenges = [ + FetchTodaysDailyChallengeResponse( + dailyChallenge: .init( + endsAt: self.mainRunLoop.now.date, + gameMode: .unlimited, + id: .init(rawValue: .dailyChallengeId), + language: .en + ), + yourResult: .init(outOf: 0, rank: nil, score: nil) + ), + FetchTodaysDailyChallengeResponse( + dailyChallenge: .init( + endsAt: self.mainRunLoop.now.date, + gameMode: .timed, + id: .init(rawValue: .dailyChallengeId), + language: .en + ), + yourResult: .init(outOf: 0, rank: nil, score: nil) + ), + ] + let weekInReview = FetchWeekInReviewResponse(ranks: [], word: nil) + let store = TestStore( initialState: .init(), reducer: appReducer, environment: update(.didFinishLaunching) { - $0.apiClient.authenticate = { _ in .init(value: .mock) } + $0.apiClient.authenticate = { _ in .mock } + $0.build.number = { 42 } $0.apiClient.currentPlayer = { nil } - $0.apiClient.override(route: .dailyChallenge(.today(language: .en)), withResponse: .none) - $0.apiClient - .override(route: .leaderboard(.weekInReview(language: .en)), withResponse: .none) + $0.apiClient.apiRequest = { @Sendable route in + switch route { + case .dailyChallenge(.today): + return try (JSONEncoder().encode(dailyChallenges), .init()) + case .leaderboard(.weekInReview): + return try (JSONEncoder().encode(weekInReview), .init()) + default: + return try await Task.never() + } + } $0.backgroundQueue = self.backgroundQueue.eraseToAnyScheduler() $0.deviceId.id = { .deviceId } - $0.gameCenter.localPlayer.authenticate = .init(value: nil) - $0.gameCenter.localPlayer.listener = listener.eraseToEffect() + $0.fileClient.save = { @Sendable _, _ in } + $0.gameCenter.localPlayer.authenticate = {} + $0.gameCenter.localPlayer.listener = { listener.stream } $0.gameCenter.localPlayer.localPlayer = { .mock } - $0.gameCenter.turnBasedMatch.saveCurrentTurn = { _, _ in .none } - $0.gameCenter.turnBasedMatch.loadMatches = { .init(value: []) } - $0.gameCenter.turnBasedMatchmakerViewController.dismiss = .none + $0.gameCenter.turnBasedMatch.saveCurrentTurn = { _, _ in } + $0.gameCenter.turnBasedMatch.loadMatches = { [] } + $0.gameCenter.turnBasedMatchmakerViewController.dismiss = {} $0.serverConfig.config = { .init() } $0.timeZone = { .newYork } } ) - store.send(.appDelegate(.didFinishLaunching)) - store.send(.home(.onAppear)) - store.receive(.home(.authenticationResponse(.mock))) + let didFinishLaunchingTask = await store.send(.appDelegate(.didFinishLaunching)) + let homeTask = await store.send(.home(.task)) - self.backgroundQueue.advance() - store.receive(.home(.set(\.$hasPastTurnBasedGames, false))) - store.receive(.home(.matchesLoaded(.success([])))) + await self.backgroundQueue.advance() + await store.receive(.home(.authenticationResponse(.mock))) + await store.receive(.home(.serverConfigResponse(.init()))) { + $0.home.hasChangelog = true + } + await store.receive(.home(.dailyChallengeResponse(.success(dailyChallenges)))) { + $0.home.dailyChallenges = dailyChallenges + } + await store.receive(.home(.weekInReviewResponse(.success(weekInReview)))) { + $0.home.weekInReview = weekInReview + } - listener.send(.turnBased(.receivedTurnEventForMatch(.inProgress, didBecomeActive: true))) + await store.receive( + .home(.activeMatchesResponse(.success(.init(matches: [], hasPastTurnBasedGames: false)))) + ) + + listener.continuation + .yield(.turnBased(.receivedTurnEventForMatch(.inProgress, didBecomeActive: true))) - store.receive( + await store.receive( .gameCenter( .listener( .turnBased( @@ -322,48 +405,93 @@ class TurnBasedTests: XCTestCase { ) ) { $0.gameCurrentTime = self.mainRunLoop.now.date } } + await store.receive( + .home(.activeMatchesResponse(.success(.init(matches: [], hasPastTurnBasedGames: false)))) + ) - self.backgroundQueue.advance() - store.receive(.home(.set(\.$hasPastTurnBasedGames, false))) - store.receive(.home(.matchesLoaded(.success([])))) + await self.backgroundQueue.advance() - listener.send(completion: .finished) + await homeTask.cancel() + await didFinishLaunchingTask.cancel() } - func testResumeForfeitedGame() { - let listener = PassthroughSubject() + func testResumeForfeitedGame() async { + let listener = AsyncStreamProducer() + + let dailyChallenges = [ + FetchTodaysDailyChallengeResponse( + dailyChallenge: .init( + endsAt: self.mainRunLoop.now.date, + gameMode: .unlimited, + id: .init(rawValue: .dailyChallengeId), + language: .en + ), + yourResult: .init(outOf: 0, rank: nil, score: nil) + ), + FetchTodaysDailyChallengeResponse( + dailyChallenge: .init( + endsAt: self.mainRunLoop.now.date, + gameMode: .timed, + id: .init(rawValue: .dailyChallengeId), + language: .en + ), + yourResult: .init(outOf: 0, rank: nil, score: nil) + ), + ] + let weekInReview = FetchWeekInReviewResponse(ranks: [], word: nil) + let store = TestStore( initialState: .init(), reducer: appReducer, environment: update(.didFinishLaunching) { - $0.apiClient.authenticate = { _ in .init(value: .mock) } + $0.apiClient.authenticate = { _ in .mock } $0.apiClient.currentPlayer = { nil } - $0.apiClient.override(route: .dailyChallenge(.today(language: .en)), withResponse: .none) - $0.apiClient - .override(route: .leaderboard(.weekInReview(language: .en)), withResponse: .none) + $0.apiClient.apiRequest = { @Sendable route in + switch route { + case .dailyChallenge(.today): + return try (JSONEncoder().encode(dailyChallenges), .init()) + case .leaderboard(.weekInReview): + return try (JSONEncoder().encode(weekInReview), .init()) + default: + return try await Task.never() + } + } $0.backgroundQueue = self.backgroundQueue.eraseToAnyScheduler() + $0.build.number = { 42 } $0.deviceId.id = { .deviceId } - $0.gameCenter.localPlayer.authenticate = .init(value: nil) - $0.gameCenter.localPlayer.listener = listener.eraseToEffect() + $0.fileClient.save = { @Sendable _, _ in } + $0.gameCenter.localPlayer.authenticate = {} + $0.gameCenter.localPlayer.listener = { listener.stream } $0.gameCenter.localPlayer.localPlayer = { .mock } - $0.gameCenter.turnBasedMatch.loadMatches = { .init(value: []) } - $0.gameCenter.turnBasedMatchmakerViewController.dismiss = .none + $0.gameCenter.turnBasedMatch.loadMatches = { [] } + $0.gameCenter.turnBasedMatchmakerViewController.dismiss = {} $0.serverConfig.config = { .init() } $0.timeZone = { .newYork } } ) - store.send(.appDelegate(.didFinishLaunching)) - store.send(.home(.onAppear)) - store.receive(.home(.authenticationResponse(.mock))) + let didFinishLaunchingTask = await store.send(.appDelegate(.didFinishLaunching)) + let homeTask = await store.send(.home(.task)) + await store.receive(.home(.authenticationResponse(.mock))) - self.backgroundQueue.advance() - store.receive(.home(.set(\.$hasPastTurnBasedGames, false))) - store.receive(.home(.matchesLoaded(.success([])))) + await store.receive(.home(.serverConfigResponse(.init()))) { + $0.home.hasChangelog = true + } + await store.receive(.home(.dailyChallengeResponse(.success(dailyChallenges)))) { + $0.home.dailyChallenges = dailyChallenges + } + await store.receive(.home(.weekInReviewResponse(.success(weekInReview)))) { + $0.home.weekInReview = weekInReview + } + await self.backgroundQueue.advance() + await store.receive( + .home(.activeMatchesResponse(.success(.init(matches: [], hasPastTurnBasedGames: false)))) + ) - listener.send(.turnBased(.receivedTurnEventForMatch(.forfeited, didBecomeActive: true))) + listener.continuation + .yield(.turnBased(.receivedTurnEventForMatch(.forfeited, didBecomeActive: true))) - store.receive( + await store.receive( .gameCenter( .listener( .turnBased( @@ -400,30 +528,29 @@ class TurnBasedTests: XCTestCase { ) $0.game = gameState } + await store.receive( + .home(.activeMatchesResponse(.success(.init(matches: [], hasPastTurnBasedGames: false)))) + ) - self.backgroundQueue.advance() - store.receive(.home(.set(\.$hasPastTurnBasedGames, false))) - store.receive(.home(.matchesLoaded(.success([])))) + await self.backgroundQueue.advance() - listener.send(completion: .finished) + await homeTask.cancel() + await didFinishLaunchingTask.cancel() } - func testRemovingCubes() { - var didEndTurnWithRequest: TurnBasedMatchClient.EndTurnRequest? + func testRemovingCubes() async throws { + let didEndTurnWithRequest = ActorIsolated(nil) let match = update(TurnBasedMatch.inProgress) { $0.creationDate = self.mainRunLoop.now.date.addingTimeInterval(-60*5) $0.participants = [.local, .remote] } - let environment = update(AppEnvironment.failing) { + let environment = update(AppEnvironment.unimplemented) { $0.apiClient.currentPlayer = { nil } - $0.audioPlayer.play = { _ in .none } + $0.audioPlayer.play = { _ in } $0.gameCenter.localPlayer.localPlayer = { .mock } - $0.gameCenter.turnBasedMatch.saveCurrentTurn = { _, _ in .init(value: ()) } - $0.gameCenter.turnBasedMatch.endTurn = { - didEndTurnWithRequest = $0 - return .init(value: ()) - } + $0.gameCenter.turnBasedMatch.saveCurrentTurn = { _, _ in } + $0.gameCenter.turnBasedMatch.endTurn = { await didEndTurnWithRequest.setValue($0) } $0.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler() } @@ -447,7 +574,7 @@ class TurnBasedTests: XCTestCase { environment: environment ) - store.send(.currentGame(.game(.doubleTap(index: .zero)))) { + await store.send(.currentGame(.game(.doubleTap(index: .zero)))) { try XCTUnwrap(&$0.game) { $0.bottomMenu = .removeCube(index: .zero, state: $0, isTurnEndingRemoval: false) } @@ -479,12 +606,12 @@ class TurnBasedTests: XCTestCase { ) ) } - store.environment.gameCenter.turnBasedMatch.load = { _ in .init(value: updatedMatch) } + store.environment.gameCenter.turnBasedMatch.load = { [updatedMatch] _ in updatedMatch } - store.send(.currentGame(.game(.confirmRemoveCube(.zero)))) { + await store.send(.currentGame(.game(.confirmRemoveCube(.zero)))) { $0.game = updatedGameState } - store.receive( + await store.receive( .currentGame(.game(.gameCenter(.turnBasedMatchResponse(.success(updatedMatch))))) ) { try XCTUnwrap(&$0.game) { @@ -493,7 +620,7 @@ class TurnBasedTests: XCTestCase { } } } - store.send(.currentGame(.game(.doubleTap(index: .init(x: .zero, y: .zero, z: .one))))) { + await store.send(.currentGame(.game(.doubleTap(index: .init(x: .zero, y: .zero, z: .one))))) { try XCTUnwrap(&$0.game) { $0.bottomMenu = .removeCube( index: .init(x: .zero, y: .zero, z: .one), @@ -531,61 +658,62 @@ class TurnBasedTests: XCTestCase { ) ) } - store.environment.gameCenter.turnBasedMatch.load = { _ in .init(value: updatedMatch) } + store.environment.gameCenter.turnBasedMatch.load = { [updatedMatch] _ in updatedMatch } - store.send(.currentGame(.game(.confirmRemoveCube(.init(x: .zero, y: .zero, z: .one))))) { + await store.send(.currentGame(.game(.confirmRemoveCube(.init(x: .zero, y: .zero, z: .one))))) { $0.game = updatedGameState } - store.receive(.currentGame(.game(.gameCenter(.turnBasedMatchResponse(.success(updatedMatch)))))) { + await store.receive(.currentGame(.game(.gameCenter(.turnBasedMatchResponse(.success(updatedMatch)))))) { try XCTUnwrap(&$0.game) { try XCTUnwrap(&$0.turnBasedContext) { $0.match = updatedMatch } } } - store.send(.currentGame(.game(.doubleTap(index: .init(x: .zero, y: .zero, z: .two))))) - - XCTAssertNoDifference( - didEndTurnWithRequest, - .init( - for: match.matchId, - matchData: Data( - turnBasedMatchData: TurnBasedMatchData( - context: try XCTUnwrap(store.state.game?.turnBasedContext), - gameState: try XCTUnwrap(store.state.game), - playerId: nil - ) - ), - message: "Blob removed cubes!" + await store.send(.currentGame(.game(.doubleTap(index: .init(x: .zero, y: .zero, z: .two))))) + + try await didEndTurnWithRequest.withValue { + XCTAssertNoDifference( + $0, + .init( + for: match.matchId, + matchData: Data( + turnBasedMatchData: TurnBasedMatchData( + context: try XCTUnwrap(store.state.game?.turnBasedContext), + gameState: try XCTUnwrap(store.state.game), + playerId: nil + ) + ), + message: "Blob removed cubes!" + ) ) - ) + } } - func testRematch() { + func testRematch() async { let localParticipant = TurnBasedParticipant.local let match = update(TurnBasedMatch.inProgress) { $0.currentParticipant = localParticipant $0.creationDate = self.mainRunLoop.now.date.addingTimeInterval(-60*5) $0.participants = [localParticipant, .remote] } - var didRematchWithId: TurnBasedMatch.Id? + let didRematchWithId = ActorIsolated(nil) let newMatch = update(TurnBasedMatch.new) { $0.creationDate = self.mainRunLoop.now.date } - let environment = update(AppEnvironment.failing) { + let environment = update(AppEnvironment.unimplemented) { $0.apiClient.currentPlayer = { nil } $0.dictionary.randomCubes = { _ in .mock } - $0.fileClient.load = { _ in .none } + $0.fileClient.load = { @Sendable _ in try await Task.never() } $0.gameCenter.localPlayer.localPlayer = { update(.authenticated) { $0.player = localParticipant.player! } } - $0.gameCenter.turnBasedMatch.loadMatches = { .none } $0.gameCenter.turnBasedMatch.rematch = { - didRematchWithId = $0 - return .init(value: newMatch) + await didRematchWithId.setValue($0) + return newMatch } - $0.gameCenter.turnBasedMatch.saveCurrentTurn = { _, _ in .none } - $0.gameCenter.turnBasedMatchmakerViewController.dismiss = .none + $0.gameCenter.turnBasedMatch.saveCurrentTurn = { _, _ in } + $0.gameCenter.turnBasedMatchmakerViewController.dismiss = {} $0.mainQueue = self.mainQueue.eraseToAnyScheduler() $0.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler() } @@ -617,13 +745,13 @@ class TurnBasedTests: XCTestCase { environment: environment ) - store.send(.currentGame(.game(.gameOver(.rematchButtonTapped)))) { + await store.send(.currentGame(.game(.gameOver(.rematchButtonTapped)))) { $0.game = nil } - XCTAssertNoDifference(didRematchWithId, match.matchId) - self.mainQueue.advance() + await didRematchWithId.withValue { XCTAssertNoDifference($0, match.matchId) } + await self.mainQueue.advance() - store.receive(.gameCenter(.rematchResponse(.success(newMatch)))) { + await store.receive(.gameCenter(.rematchResponse(.success(newMatch)))) { $0.currentGame = GameFeatureState( game: GameState( cubes: .mock, @@ -643,7 +771,7 @@ class TurnBasedTests: XCTestCase { } } - func testGameCenterNotification_ShowsRecentTurn() { + func testGameCenterNotification_ShowsRecentTurn() async { let localParticipant = TurnBasedParticipant.local let remoteParticipant = update(TurnBasedParticipant.remote) { $0.lastTurnDate = self.mainRunLoop.now.date - 10 @@ -680,14 +808,10 @@ class TurnBasedTests: XCTestCase { $0.message = "Blob played ABC!" } - var notificationBannerRequest: GameCenterClient.NotificationBannerRequest? - let environment = update(AppEnvironment.failing) { + let notificationBannerRequest = ActorIsolated(nil) + let environment = update(AppEnvironment.unimplemented) { $0.gameCenter.localPlayer.localPlayer = { .authenticated } - $0.gameCenter.showNotificationBanner = { - notificationBannerRequest = $0 - return .none - } - $0.gameCenter.turnBasedMatch.loadMatches = { .none } + $0.gameCenter.showNotificationBanner = { await notificationBannerRequest.setValue($0) } $0.mainQueue = self.mainQueue.eraseToAnyScheduler() $0.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler() } @@ -698,23 +822,25 @@ class TurnBasedTests: XCTestCase { environment: environment ) - store.send( + await store.send( .gameCenter( .listener(.turnBased(.receivedTurnEventForMatch(match, didBecomeActive: false))) ) ) - self.mainQueue.advance() - XCTAssertNoDifference( - notificationBannerRequest, - GameCenterClient.NotificationBannerRequest( - title: "Blob played ABC!", - message: nil + await self.mainQueue.advance() + await notificationBannerRequest.withValue { + XCTAssertNoDifference( + $0, + GameCenterClient.NotificationBannerRequest( + title: "Blob played ABC!", + message: nil + ) ) - ) + } } - func testGameCenterNotification_DoesNotShow() { + func testGameCenterNotification_DoesNotShow() async { let localParticipant = TurnBasedParticipant.local let remoteParticipant = update(TurnBasedParticipant.remote) { $0.lastTurnDate = self.mainRunLoop.now.date - 10 @@ -745,9 +871,8 @@ class TurnBasedTests: XCTestCase { $0.message = "Let's play!" } - let environment = update(AppEnvironment.failing) { + let environment = update(AppEnvironment.unimplemented) { $0.gameCenter.localPlayer.localPlayer = { .authenticated } - $0.gameCenter.turnBasedMatch.loadMatches = { .none } $0.mainQueue = self.mainQueue.eraseToAnyScheduler() $0.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler() } @@ -758,11 +883,11 @@ class TurnBasedTests: XCTestCase { environment: environment ) - store.send( + await store.send( .gameCenter( .listener(.turnBased(.receivedTurnEventForMatch(match, didBecomeActive: false))) ) ) - self.mainQueue.run() + await self.mainQueue.run() } } diff --git a/Tests/AppFeatureTests/UserNotificationsTests.swift b/Tests/AppFeatureTests/UserNotificationsTests.swift index f654d6f6..5400e6de 100644 --- a/Tests/AppFeatureTests/UserNotificationsTests.swift +++ b/Tests/AppFeatureTests/UserNotificationsTests.swift @@ -7,9 +7,10 @@ import XCTest @testable import AppFeature +@MainActor class UserNotificationsTests: XCTestCase { - func testReceiveBackgroundNotification() { - let delegate = PassthroughSubject() + func testReceiveBackgroundNotification() async { + let delegate = AsyncStream.streamWithContinuation() let response = UserNotificationClient.Notification.Response( notification: UserNotificationClient.Notification( date: .mock, @@ -27,29 +28,30 @@ class UserNotificationsTests: XCTestCase { initialState: .init(), reducer: appReducer, environment: update(.didFinishLaunching) { - $0.userNotifications.delegate = delegate.eraseToEffect() + $0.userNotifications.delegate = { delegate.stream } } ) - store.send(.appDelegate(.didFinishLaunching)) + let task = await store.send(.appDelegate(.didFinishLaunching)) - delegate.send(.didReceiveResponse(response, completionHandler: completionHandler)) + delegate.continuation.yield( + .didReceiveResponse(response, completionHandler: { completionHandler() }) + ) - store.receive( + await store.receive( .appDelegate( .userNotifications( - .didReceiveResponse(response, completionHandler: completionHandler) + .didReceiveResponse(response, completionHandler: { completionHandler() }) ) ) ) - XCTAssertTrue(didCallback) - delegate.send(completion: .finished) + await task.cancel() } - func testReceiveForegroundNotification() { - let delegate = PassthroughSubject() + func testReceiveForegroundNotification() async { + let delegate = AsyncStream.streamWithContinuation() let notification = UserNotificationClient.Notification( date: .mock, request: UNNotificationRequest( @@ -65,24 +67,26 @@ class UserNotificationsTests: XCTestCase { initialState: .init(), reducer: appReducer, environment: update(.didFinishLaunching) { - $0.userNotifications.delegate = delegate.eraseToEffect() + $0.userNotifications.delegate = { delegate.stream } } ) - store.send(.appDelegate(.didFinishLaunching)) + let task = await store.send(.appDelegate(.didFinishLaunching)) - delegate.send(.willPresentNotification(notification, completionHandler: completionHandler)) + delegate.continuation.yield( + .willPresentNotification(notification, completionHandler: { completionHandler($0) }) + ) - store.receive( + await store.receive( .appDelegate( .userNotifications( - .willPresentNotification(notification, completionHandler: completionHandler) + .willPresentNotification(notification, completionHandler: { completionHandler($0) }) ) ) ) XCTAssertNoDifference(didCallbackWithOptions, .banner) - delegate.send(completion: .finished) + await task.cancel() } } diff --git a/Tests/AppSiteAssociationMiddlewareTests/AppSiteAssociationMiddlewareTests.swift b/Tests/AppSiteAssociationMiddlewareTests/AppSiteAssociationMiddlewareTests.swift index 6aeb3f16..fba35322 100644 --- a/Tests/AppSiteAssociationMiddlewareTests/AppSiteAssociationMiddlewareTests.swift +++ b/Tests/AppSiteAssociationMiddlewareTests/AppSiteAssociationMiddlewareTests.swift @@ -15,7 +15,7 @@ import XCTest class AppSiteAssociationMiddlewareTests: XCTestCase { func testBasics() throws { let request = URLRequest(url: URL(string: "/.well-known/apple-app-site-association")!) - let middleware = siteMiddleware(environment: .failing) + let middleware = siteMiddleware(environment: .unimplemented) let result = middleware(connection(from: request)).perform() _assertInlineSnapshot(matching: result, as: .conn, with: #""" diff --git a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_1_SoloGame.iPad_12_9.png b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_1_SoloGame.iPad_12_9.png index e672a019..fb5555f7 100644 --- a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_1_SoloGame.iPad_12_9.png +++ b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_1_SoloGame.iPad_12_9.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:10a0ee1321a8ccbdb660e7ff2845354f34f766c7c0bad54ab8b8052214f6a5bc -size 6011266 +oid sha256:73749a4934e4a69fd55c854684368fc624b4b98c035dbfc3d4f18a515b19f5d3 +size 6029524 diff --git a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_1_SoloGame.iPhone_5_5.png b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_1_SoloGame.iPhone_5_5.png index a771e8de..4d28419a 100644 --- a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_1_SoloGame.iPhone_5_5.png +++ b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_1_SoloGame.iPhone_5_5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7e5af9b7c1f5c3b66dfb7e8e2fe65bc19e0e827db42767fd126c9ead60f9ecfb -size 1471103 +oid sha256:370b1f00f8c81592318762046b450220ee638e5ba381d5ee207693bbf6e4b3b3 +size 1476251 diff --git a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_1_SoloGame.iPhone_6_5.png b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_1_SoloGame.iPhone_6_5.png index 16a2e221..4ecd63b1 100644 --- a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_1_SoloGame.iPhone_6_5.png +++ b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_1_SoloGame.iPhone_6_5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e114c35ec0da44a59d675e0dedf6a40718896f63f71c24ad48d4c6c07a32fdc0 -size 1932077 +oid sha256:5e203405a09b1d12dc489512b927006ca4142f1a07bd2a511b9e9cbe340037a6 +size 1944804 diff --git a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_2_TurnBasedGame.iPad_12_9.png b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_2_TurnBasedGame.iPad_12_9.png index 947487d7..b37800ef 100644 --- a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_2_TurnBasedGame.iPad_12_9.png +++ b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_2_TurnBasedGame.iPad_12_9.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4ee058120065feddd4956c7a82f51d9e3c16461c986ae5358ba35617cc05fb03 -size 1410798 +oid sha256:6e5771a876c56acecccb5100a7ba60fe2db98a157aade84563e1c3da6efbd10d +size 1409919 diff --git a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_2_TurnBasedGame.iPhone_5_5.png b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_2_TurnBasedGame.iPhone_5_5.png index da84bb66..7154eafa 100644 --- a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_2_TurnBasedGame.iPhone_5_5.png +++ b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_2_TurnBasedGame.iPhone_5_5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ecfc74444d4283ad6daff7ab102a18bb2c0059727bcca24430466ba1255880cb -size 504402 +oid sha256:b03f54440fecb15d34fcfee798e61db64672f381b3fcfec31153e38ed51a00ba +size 507078 diff --git a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_2_TurnBasedGame.iPhone_6_5.png b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_2_TurnBasedGame.iPhone_6_5.png index b0a0d27e..341c68b7 100644 --- a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_2_TurnBasedGame.iPhone_6_5.png +++ b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_2_TurnBasedGame.iPhone_6_5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a6b9e5e8de1995776f11a49e10e5ee402ebe3d4d771c11e4f00c1ef414099ed0 -size 556521 +oid sha256:c54a3998c56dfb7f26f603990719f91265655f35b8c5662570b807cfa1825fce +size 555962 diff --git a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_3_DailyChallengeResults.iPad_12_9.png b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_3_DailyChallengeResults.iPad_12_9.png index 7863816f..5bfb3e55 100644 --- a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_3_DailyChallengeResults.iPad_12_9.png +++ b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_3_DailyChallengeResults.iPad_12_9.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aec0a70508571b7b19cc9f11b09cb9c8c7427142e3a03cfd74f9dc9b98b0c8ca -size 920651 +oid sha256:39a5a4d7c54b25c6885f3932ea792a9d59d64051906a1fa339f232dabe8f7f8d +size 922931 diff --git a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_3_DailyChallengeResults.iPhone_5_5.png b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_3_DailyChallengeResults.iPhone_5_5.png index 9e9897a2..d37eece3 100644 --- a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_3_DailyChallengeResults.iPhone_5_5.png +++ b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_3_DailyChallengeResults.iPhone_5_5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb4061ae4ea7ffcd419882f8699516dbfc9e0d1dc187fa3ab65639b305944e65 -size 309311 +oid sha256:d1ed8ad5e300f54f75793247ad02f6ad1111f14fca941544cff5876f6c6e6593 +size 310968 diff --git a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_3_DailyChallengeResults.iPhone_6_5.png b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_3_DailyChallengeResults.iPhone_6_5.png index 4a45adfb..795bb71a 100644 --- a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_3_DailyChallengeResults.iPhone_6_5.png +++ b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_3_DailyChallengeResults.iPhone_6_5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:13f0505bf3ac9223a078e30179ff1ee958133a69b8390b9d27a4f08465160456 -size 424143 +oid sha256:a255a45bc2e1099c6530f1291185632bcfc4a3404358c1799372c8dbcc7941d2 +size 425415 diff --git a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_4_Leaderboards.iPad_12_9.png b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_4_Leaderboards.iPad_12_9.png index de103d6a..08625792 100644 --- a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_4_Leaderboards.iPad_12_9.png +++ b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_4_Leaderboards.iPad_12_9.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e89c87e7bb52851b05939dd1466fac443dc5721908b035c1c0af658463075c8 -size 843139 +oid sha256:06931bc2888e99a717260d3294ecec910514d14ec25df51f1966e4687f33faf5 +size 850540 diff --git a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_4_Leaderboards.iPhone_5_5.png b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_4_Leaderboards.iPhone_5_5.png index a54394e4..3c65cd10 100644 --- a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_4_Leaderboards.iPhone_5_5.png +++ b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_4_Leaderboards.iPhone_5_5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2dc607360e8ea6d42e3bf69e6bc4ff229c33c136a0baf86f8eca9a158d2dfdb0 -size 280254 +oid sha256:47c5babb635b29cd3b600baf11f44cc20bb03edeadbaa931399f5bf151c790a7 +size 284640 diff --git a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_4_Leaderboards.iPhone_6_5.png b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_4_Leaderboards.iPhone_6_5.png index 94263c0b..80824ad8 100644 --- a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_4_Leaderboards.iPhone_6_5.png +++ b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_4_Leaderboards.iPhone_6_5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0970f2147a8018039ded8b5c36cb1bfd32176598d13caf895ebd9aba7b6e7e1f -size 385435 +oid sha256:18eab26f0c3963b459c7946c461ae8af869239362f2ae0940df117d33081b7c8 +size 386455 diff --git a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_5_Home.iPad_12_9.png b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_5_Home.iPad_12_9.png index 30ec04d8..a59fe6fd 100644 --- a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_5_Home.iPad_12_9.png +++ b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_5_Home.iPad_12_9.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b1d6a964efd842f4dc1ab88d10bbe62fd7b2c73826100a341a8d7ee7d90d3f3d -size 1040527 +oid sha256:be5541b5fefd399eba08b030cc62190ca0a73057c2151072e19cfa754ea4dd38 +size 1043049 diff --git a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_5_Home.iPhone_5_5.png b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_5_Home.iPhone_5_5.png index 164f51d8..9e6715fb 100644 --- a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_5_Home.iPhone_5_5.png +++ b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_5_Home.iPhone_5_5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ca35ec492d4cb8e60e687e7b715a04184989ba60ccce9230f9584af5ad807b17 -size 352766 +oid sha256:ecb12b95c092baefad62c2081a763a6d5bb9e1c8d9ee73c0ca8645fac7de7e38 +size 353669 diff --git a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_5_Home.iPhone_6_5.png b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_5_Home.iPhone_6_5.png index 74db73e3..5381d7d5 100644 --- a/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_5_Home.iPhone_6_5.png +++ b/Tests/AppStoreSnapshotTests/__Snapshots__/AppStoreSnapshotTests/test_5_Home.iPhone_6_5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f8bb762f6bb72410be0a4bd6b499d0faa8bb4ba36b9e9a0e44a4d966ea18f390 -size 441325 +oid sha256:b372027bcd7be2197a788728cf15c6aec26a86d980b3c51c33b272b8a5bc4f82 +size 442586 diff --git a/Tests/ChangelogFeatureTests/ChangelogFeatureTests.swift b/Tests/ChangelogFeatureTests/ChangelogFeatureTests.swift index 1b3ac4a0..5efe96ad 100644 --- a/Tests/ChangelogFeatureTests/ChangelogFeatureTests.swift +++ b/Tests/ChangelogFeatureTests/ChangelogFeatureTests.swift @@ -1,3 +1,4 @@ +import ApiClient import ComposableArchitecture import ServerConfig import XCTest @@ -5,8 +6,9 @@ import XCTest @testable import ChangelogFeature @testable import UserDefaultsClient +@MainActor class ChangelogFeatureTests: XCTestCase { - func testOnAppear_IsUpToDate() { + func testOnAppear_IsUpToDate() async { let changelog = Changelog( changes: [ .init(version: "1.2", build: 42, log: "Bug fixes and improvements"), @@ -14,13 +16,12 @@ class ChangelogFeatureTests: XCTestCase { ] ) - var environment = ChangelogEnvironment.failing + var environment = ChangelogEnvironment.unimplemented environment.apiClient.override( route: .changelog(build: 42), - withResponse: .ok(changelog) + withResponse: { try await OK(changelog) } ) environment.build.number = { 42 } - environment.mainQueue = .immediate let store = TestStore( initialState: ChangelogState(), @@ -28,11 +29,11 @@ class ChangelogFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) { + await store.send(.task) { $0.currentBuild = 42 $0.isRequestInFlight = true } - store.receive(.changelogResponse(.success(changelog))) { + await store.receive(.changelogResponse(.success(changelog))) { $0.changelog = [ .init( change: .init(version: "1.2", build: 42, log: "Bug fixes and improvements"), @@ -47,7 +48,7 @@ class ChangelogFeatureTests: XCTestCase { } } - func testOnAppear_IsUpBehind() { + func testOnAppear_IsUpBehind() async { let changelog = Changelog( changes: [ .init(version: "1.2", build: 42, log: "Bug fixes and improvements"), @@ -56,13 +57,12 @@ class ChangelogFeatureTests: XCTestCase { ] ) - var environment = ChangelogEnvironment.failing + var environment = ChangelogEnvironment.unimplemented environment.apiClient.override( route: .changelog(build: 40), - withResponse: .ok(changelog) + withResponse: { try await OK(changelog) } ) environment.build.number = { 40 } - environment.mainQueue = .immediate let store = TestStore( initialState: ChangelogState(), @@ -70,11 +70,11 @@ class ChangelogFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) { + await store.send(.task) { $0.currentBuild = 40 $0.isRequestInFlight = true } - store.receive(.changelogResponse(.success(changelog))) { + await store.receive(.changelogResponse(.success(changelog))) { $0.changelog = [ .init( change: .init(version: "1.2", build: 42, log: "Bug fixes and improvements"), @@ -96,12 +96,11 @@ class ChangelogFeatureTests: XCTestCase { } extension ChangelogEnvironment { - static let failing = Self( - apiClient: .failing, - applicationClient: .failing, - build: .failing, - mainQueue: .failing, - serverConfig: .failing, - userDefaults: .failing + static let unimplemented = Self( + apiClient: .unimplemented, + applicationClient: .unimplemented, + build: .unimplemented, + serverConfig: .unimplemented, + userDefaults: .unimplemented ) } diff --git a/Tests/DailyChallengeFeatureIntegrationTests/DailyChallengeFeatureIntegrationTests.swift b/Tests/DailyChallengeFeatureIntegrationTests/DailyChallengeFeatureIntegrationTests.swift index ac925bdf..2ca3baf8 100644 --- a/Tests/DailyChallengeFeatureIntegrationTests/DailyChallengeFeatureIntegrationTests.swift +++ b/Tests/DailyChallengeFeatureIntegrationTests/DailyChallengeFeatureIntegrationTests.swift @@ -12,8 +12,9 @@ import XCTest @testable import LeaderboardFeature +@MainActor class DailyChallengeFeatureTests: XCTestCase { - func testBasics() { + func testBasics() async { let uuid = UUID.incrementing let currentPlayer = Player.blob @@ -56,7 +57,7 @@ class DailyChallengeFeatureTests: XCTestCase { rank: 1 ) - let serverEnvironment = update(ServerEnvironment.failing) { + let serverEnvironment = update(ServerEnvironment.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(currentPlayer) } $0.database.fetchDailyChallengeResults = { request in switch request.gameMode { @@ -72,8 +73,7 @@ class DailyChallengeFeatureTests: XCTestCase { } let clientEnvironment = DailyChallengeResultsEnvironment( - apiClient: .init(middleware: siteMiddleware(environment: serverEnvironment), router: .test), - mainQueue: .immediate + apiClient: .init(middleware: siteMiddleware(environment: serverEnvironment), router: .test) ) let store = TestStore( @@ -82,16 +82,16 @@ class DailyChallengeFeatureTests: XCTestCase { environment: clientEnvironment ) - store.send(.leaderboardResults(.onAppear)) { + await store.send(.leaderboardResults(.task)) { $0.leaderboardResults.isLoading = true $0.leaderboardResults.resultEnvelope = .placeholder } - store.receive(.leaderboardResults(.resultsResponse(.success(timedResultEnvelope)))) { + await store.receive(.leaderboardResults(.resultsResponse(.success(timedResultEnvelope)))) { $0.leaderboardResults.isLoading = false $0.leaderboardResults.resultEnvelope = timedResultEnvelope } - store.send(.loadHistory) - store.receive(.fetchHistoryResponse(.success(.init(results: [historyResult])))) { + await store.send(.loadHistory) + await store.receive(.fetchHistoryResponse(.success(.init(results: [historyResult])))) { $0.history = .init(results: [historyResult]) } } diff --git a/Tests/DailyChallengeFeatureTests/DailyChallengeFeatureTests.swift b/Tests/DailyChallengeFeatureTests/DailyChallengeFeatureTests.swift index c0938e33..e02b6be2 100644 --- a/Tests/DailyChallengeFeatureTests/DailyChallengeFeatureTests.swift +++ b/Tests/DailyChallengeFeatureTests/DailyChallengeFeatureTests.swift @@ -1,3 +1,4 @@ +import ApiClient import ClientModels import ComposableArchitecture import XCTest @@ -5,20 +6,21 @@ import XCTest @testable import DailyChallengeFeature @testable import SharedModels +@MainActor class DailyChallengeFeatureTests: XCTestCase { let mainQueue = DispatchQueue.test let mainRunLoop = RunLoop.test - func testOnAppear() { - var environment = DailyChallengeEnvironment.failing + func testOnAppear() async { + var environment = DailyChallengeEnvironment.unimplemented environment.apiClient.override( route: .dailyChallenge(.today(language: .en)), - withResponse: .ok([FetchTodaysDailyChallengeResponse.played]) + withResponse: { try await OK([FetchTodaysDailyChallengeResponse.played]) } ) environment.mainRunLoop = .immediate - environment.userNotifications.getNotificationSettings = .init( - value: .init(authorizationStatus: .authorized) - ) + environment.userNotifications.getNotificationSettings = { + .init(authorizationStatus: .authorized) + } let store = TestStore( initialState: .init(), @@ -26,28 +28,27 @@ class DailyChallengeFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) - - store.receive(.fetchTodaysDailyChallengeResponse(.success([.played]))) { - $0.dailyChallenges = [.played] - } + await store.send(.task) - store.receive(.userNotificationSettingsResponse(.init(authorizationStatus: .authorized))) { + await store.receive(.userNotificationSettingsResponse(.init(authorizationStatus: .authorized))) { $0.userNotificationSettings = .init(authorizationStatus: .authorized) } + await store.receive(.fetchTodaysDailyChallengeResponse(.success([.played]))) { + $0.dailyChallenges = [.played] + } } - func testTapGameThatWasPlayed() { + func testTapGameThatWasPlayed() async { var dailyChallengeResponse = FetchTodaysDailyChallengeResponse.played dailyChallengeResponse.dailyChallenge.endsAt = Date().addingTimeInterval(60 * 60 * 2 + 1) let store = TestStore( initialState: DailyChallengeState(dailyChallenges: [dailyChallengeResponse]), reducer: dailyChallengeReducer, - environment: .failing + environment: .unimplemented ) - store.send(.gameButtonTapped(.unlimited)) { + await store.send(.gameButtonTapped(.unlimited)) { $0.alert = .init( title: .init("Already played"), message: .init( @@ -58,32 +59,34 @@ class DailyChallengeFeatureTests: XCTestCase { } } - func testTapGameThatWasNotStarted() { + func testTapGameThatWasNotStarted() async { var inProgressGame = InProgressGame.mock inProgressGame.gameStartTime = self.mainRunLoop.now.date inProgressGame.gameContext = .dailyChallenge(.init(rawValue: .dailyChallengeId)) - var environment = DailyChallengeEnvironment.failing + var environment = DailyChallengeEnvironment.unimplemented environment.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler() environment.apiClient.override( route: .dailyChallenge(.start(gameMode: .unlimited, language: .en)), - withResponse: .ok( - StartDailyChallengeResponse( - dailyChallenge: .init( - createdAt: .mock, - endsAt: .mock, - gameMode: .unlimited, - gameNumber: 42, - id: .init(rawValue: .dailyChallengeId), - language: .en, - puzzle: .mock - ), - dailyChallengePlayId: .init(rawValue: .deadbeef) + withResponse: { + try await OK( + StartDailyChallengeResponse( + dailyChallenge: .init( + createdAt: .mock, + endsAt: .mock, + gameMode: .unlimited, + gameNumber: 42, + id: .init(rawValue: .dailyChallengeId), + language: .en, + puzzle: .mock + ), + dailyChallengePlayId: .init(rawValue: .deadbeef) + ) ) - ) + } ) struct FileNotFound: Error {} - environment.fileClient.load = { _ in .init(error: FileNotFound()) } + environment.fileClient.load = { @Sendable _ in throw FileNotFound() } let store = TestStore( initialState: DailyChallengeState(dailyChallenges: [.notStarted]), @@ -91,18 +94,18 @@ class DailyChallengeFeatureTests: XCTestCase { environment: environment ) - store.send(.gameButtonTapped(.unlimited)) { + await store.send(.gameButtonTapped(.unlimited)) { $0.gameModeIsLoading = .unlimited } - self.mainRunLoop.advance() - store.receive(.startDailyChallengeResponse(.success(inProgressGame))) { + await self.mainRunLoop.advance() + await store.receive(.startDailyChallengeResponse(.success(inProgressGame))) { $0.gameModeIsLoading = nil } - store.receive(.delegate(.startGame(inProgressGame))) + await store.receive(.delegate(.startGame(inProgressGame))) } - func testTapGameThatWasStarted_NotPlayed_HasLocalGame() { + func testTapGameThatWasStarted_NotPlayed_HasLocalGame() async { var inProgressGame = InProgressGame.mock inProgressGame.gameStartTime = .mock inProgressGame.gameContext = .dailyChallenge(.init(rawValue: .dailyChallengeId)) @@ -110,13 +113,9 @@ class DailyChallengeFeatureTests: XCTestCase { .highScoringMove ] - var environment = DailyChallengeEnvironment.failing - environment.fileClient.load = { asdf in - .init( - value: try! JSONEncoder().encode( - SavedGamesState.init(dailyChallengeUnlimited: inProgressGame) - ) - ) + var environment = DailyChallengeEnvironment.unimplemented + environment.fileClient.load = { @Sendable [inProgressGame] _ in + try JSONEncoder().encode(SavedGamesState(dailyChallengeUnlimited: inProgressGame)) } environment.mainRunLoop = .immediate @@ -129,46 +128,42 @@ class DailyChallengeFeatureTests: XCTestCase { environment: environment ) - store.send(.gameButtonTapped(.unlimited)) { + await store.send(.gameButtonTapped(.unlimited)) { $0.gameModeIsLoading = .unlimited } - store.receive(.startDailyChallengeResponse(.success(inProgressGame))) { + await store.receive(.startDailyChallengeResponse(.success(inProgressGame))) { $0.gameModeIsLoading = nil } - store.receive(.delegate(.startGame(inProgressGame))) + await store.receive(.delegate(.startGame(inProgressGame))) } - func testNotifications_OpenThenClose() { + func testNotifications_OpenThenClose() async { let store = TestStore( initialState: DailyChallengeState(), reducer: dailyChallengeReducer, - environment: .failing + environment: .unimplemented ) - store.send(.notificationButtonTapped) { + await store.send(.notificationButtonTapped) { $0.notificationsAuthAlert = .init() } - store.send(.notificationsAuthAlert(.closeButtonTapped)) - store.receive(.notificationsAuthAlert(.delegate(.close))) { + await store.send(.notificationsAuthAlert(.closeButtonTapped)) + await store.receive(.notificationsAuthAlert(.delegate(.close))) { $0.notificationsAuthAlert = nil } } - func testNotifications_GrantAccess() { - var didRegisterForRemoteNotifications = false + func testNotifications_GrantAccess() async { + let didRegisterForRemoteNotifications = ActorIsolated(false) - var environment = DailyChallengeEnvironment.failing - environment.userNotifications.getNotificationSettings = .init( - value: .init(authorizationStatus: .authorized) - ) - environment.userNotifications.requestAuthorization = { options in - .init(value: true) + var environment = DailyChallengeEnvironment.unimplemented + environment.userNotifications.getNotificationSettings = { + .init(authorizationStatus: .authorized) } + environment.userNotifications.requestAuthorization = { _ in true } environment.remoteNotifications.register = { - .fireAndForget { - didRegisterForRemoteNotifications = true - } + await didRegisterForRemoteNotifications.setValue(true) } environment.mainRunLoop = .immediate @@ -178,32 +173,28 @@ class DailyChallengeFeatureTests: XCTestCase { environment: environment ) - store.send(.notificationButtonTapped) { + await store.send(.notificationButtonTapped) { $0.notificationsAuthAlert = .init() } - store.send(.notificationsAuthAlert(.turnOnNotificationsButtonTapped)) - store.receive( + await store.send(.notificationsAuthAlert(.turnOnNotificationsButtonTapped)) + await store.receive( .notificationsAuthAlert( - .delegate( - .didChooseNotificationSettings(.init(authorizationStatus: .authorized)) - ) + .delegate(.didChooseNotificationSettings(.init(authorizationStatus: .authorized))) ) ) { $0.notificationsAuthAlert = nil $0.userNotificationSettings = .init(authorizationStatus: .authorized) } - XCTAssertNoDifference(didRegisterForRemoteNotifications, true) + await didRegisterForRemoteNotifications.withValue { XCTAssertNoDifference($0, true) } } - func testNotifications_DenyAccess() { - var environment = DailyChallengeEnvironment.failing - environment.userNotifications.getNotificationSettings = .init( - value: .init(authorizationStatus: .denied) - ) - environment.userNotifications.requestAuthorization = { options in - .init(value: false) + func testNotifications_DenyAccess() async { + var environment = DailyChallengeEnvironment.unimplemented + environment.userNotifications.getNotificationSettings = { + .init(authorizationStatus: .denied) } + environment.userNotifications.requestAuthorization = { _ in false } environment.mainRunLoop = .immediate let store = TestStore( @@ -212,15 +203,13 @@ class DailyChallengeFeatureTests: XCTestCase { environment: environment ) - store.send(.notificationButtonTapped) { + await store.send(.notificationButtonTapped) { $0.notificationsAuthAlert = .init() } - store.send(.notificationsAuthAlert(.turnOnNotificationsButtonTapped)) - store.receive( + await store.send(.notificationsAuthAlert(.turnOnNotificationsButtonTapped)) + await store.receive( .notificationsAuthAlert( - .delegate( - .didChooseNotificationSettings(.init(authorizationStatus: .denied)) - ) + .delegate(.didChooseNotificationSettings(.init(authorizationStatus: .denied))) ) ) { $0.notificationsAuthAlert = nil @@ -230,12 +219,12 @@ class DailyChallengeFeatureTests: XCTestCase { } extension DailyChallengeEnvironment { - static let failing = Self( - apiClient: .failing, - fileClient: .failing, - mainQueue: .failing, - mainRunLoop: .failing, - remoteNotifications: .failing, - userNotifications: .failing + static let unimplemented = Self( + apiClient: .unimplemented, + fileClient: .unimplemented, + mainQueue: .unimplemented, + mainRunLoop: .unimplemented, + remoteNotifications: .unimplemented, + userNotifications: .unimplemented ) } diff --git a/Tests/DailyChallengeFeatureTests/__Snapshots__/DailyChallengeResultsViewTests/testDefault.1.png b/Tests/DailyChallengeFeatureTests/__Snapshots__/DailyChallengeResultsViewTests/testDefault.1.png index 43d23b78..f9b85d4c 100644 --- a/Tests/DailyChallengeFeatureTests/__Snapshots__/DailyChallengeResultsViewTests/testDefault.1.png +++ b/Tests/DailyChallengeFeatureTests/__Snapshots__/DailyChallengeResultsViewTests/testDefault.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:57f6d20f62a7c28077778c899f112972ced5a86704f5594c66e7e5d0e40cbaaa -size 200163 +oid sha256:d7e1b625fd59763f48676bfcb8a4567a15d0c7f342e0a63fbaf34f8708f17b82 +size 201216 diff --git a/Tests/DailyChallengeFeatureTests/__Snapshots__/DailyChallengeResultsViewTests/testTimeScopeOpened.1.png b/Tests/DailyChallengeFeatureTests/__Snapshots__/DailyChallengeResultsViewTests/testTimeScopeOpened.1.png index 18214ed7..2ebc2cbc 100644 --- a/Tests/DailyChallengeFeatureTests/__Snapshots__/DailyChallengeResultsViewTests/testTimeScopeOpened.1.png +++ b/Tests/DailyChallengeFeatureTests/__Snapshots__/DailyChallengeResultsViewTests/testTimeScopeOpened.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:87a5bf0f63850995ebaa6a3c1834121701b24f0242574fcad513228b858e7892 -size 213656 +oid sha256:dbf3ad0425b0f43bf993d9ccdc040913857fd566621a18c78c39226156ca6346 +size 214078 diff --git a/Tests/DailyChallengeFeatureTests/__Snapshots__/DailyChallengeResultsViewTests/testTimeScopeOpenedLoading.1.png b/Tests/DailyChallengeFeatureTests/__Snapshots__/DailyChallengeResultsViewTests/testTimeScopeOpenedLoading.1.png index e0d73ac6..9847f0eb 100644 --- a/Tests/DailyChallengeFeatureTests/__Snapshots__/DailyChallengeResultsViewTests/testTimeScopeOpenedLoading.1.png +++ b/Tests/DailyChallengeFeatureTests/__Snapshots__/DailyChallengeResultsViewTests/testTimeScopeOpenedLoading.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d37af7ff6ed22a3c315264b7522e35d3fdf1c0b39c75f67705e0a3c51879c272 -size 175600 +oid sha256:0ee31a5a7be0f7957b0a91861f83bdd49bae200d083b55bbc08443d08ced0903 +size 175763 diff --git a/Tests/DailyChallengeFeatureTests/__Snapshots__/DailyChallengeViewTests/testTimedGamePlayed_UnlimitedGameResumable.1.png b/Tests/DailyChallengeFeatureTests/__Snapshots__/DailyChallengeViewTests/testTimedGamePlayed_UnlimitedGameResumable.1.png index b117f465..4087a05e 100644 --- a/Tests/DailyChallengeFeatureTests/__Snapshots__/DailyChallengeViewTests/testTimedGamePlayed_UnlimitedGameResumable.1.png +++ b/Tests/DailyChallengeFeatureTests/__Snapshots__/DailyChallengeViewTests/testTimedGamePlayed_UnlimitedGameResumable.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2cdf5dc6e2c8b705b5f4489fbc9dd72ca3ebc9bc79a2570f8cfcd1c725cf1f47 -size 186247 +oid sha256:39e382b701ffe4dbdac5111277d41c155c0d212b95b89e0a1ab05c6d3610a35a +size 186314 diff --git a/Tests/DailyChallengeMiddlewareTests/DailyChallengeMiddlewareTests.swift b/Tests/DailyChallengeMiddlewareTests/DailyChallengeMiddlewareTests.swift index 95c9004f..4145ba2b 100644 --- a/Tests/DailyChallengeMiddlewareTests/DailyChallengeMiddlewareTests.swift +++ b/Tests/DailyChallengeMiddlewareTests/DailyChallengeMiddlewareTests.swift @@ -36,7 +36,7 @@ class DailyChallengeMiddlewareTests: XCTestCase { ) let middleware = siteMiddleware( - environment: update(.failing) { + environment: update(.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(.blob) } $0.database.createTodaysDailyChallenge = { request in pure( @@ -71,7 +71,7 @@ class DailyChallengeMiddlewareTests: XCTestCase { ) let middleware = siteMiddleware( - environment: update(.failing) { + environment: update(.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(.blob) } $0.database.createTodaysDailyChallenge = { request in pure( @@ -110,7 +110,7 @@ class DailyChallengeMiddlewareTests: XCTestCase { let player = Player.blob let middleware = siteMiddleware( - environment: update(.failing) { + environment: update(.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(player) } $0.database.fetchTodaysDailyChallenges = { language in pure( @@ -184,7 +184,7 @@ class DailyChallengeMiddlewareTests: XCTestCase { .base64EncodedString() ] - var environment = ServerEnvironment.failing + var environment = ServerEnvironment.unimplemented environment.database.completeDailyChallenge = { pure( DailyChallengePlay( @@ -358,7 +358,7 @@ class DailyChallengeMiddlewareTests: XCTestCase { .base64EncodedString() ] - var environment = ServerEnvironment.failing + var environment = ServerEnvironment.unimplemented environment.database.fetchPlayerByAccessToken = { _ in pure(.blob) } environment.database.fetchDailyChallengeById = { _ in pure( @@ -458,7 +458,7 @@ class DailyChallengeMiddlewareTests: XCTestCase { )! ) - var environment = ServerEnvironment.failing + var environment = ServerEnvironment.unimplemented environment.database.fetchPlayerByAccessToken = { _ in pure(.blob) } environment.database.fetchDailyChallengeResults = { request in pure( diff --git a/Tests/DailyChallengeReportsTests/DailyChallengeReportsTests.swift b/Tests/DailyChallengeReportsTests/DailyChallengeReportsTests.swift index 887a1345..c07554bf 100644 --- a/Tests/DailyChallengeReportsTests/DailyChallengeReportsTests.swift +++ b/Tests/DailyChallengeReportsTests/DailyChallengeReportsTests.swift @@ -14,7 +14,7 @@ class DailyChallengeReportsTests: XCTestCase { var pushes: [(targetArn: EndpointArn, payload: AnyEncodable)] = [] try sendDailyChallengeReports( - database: update(.failing) { + database: update(.unimplemented) { $0.fetchDailyChallengeReport = { request in switch request.gameMode { case .timed: @@ -54,7 +54,7 @@ class DailyChallengeReportsTests: XCTestCase { } } }, - sns: update(.failing) { + sns: update(.unimplemented) { $0._publish = { pushes.append(($0, $1)) return pure(.init(response: .init(result: .init(messageId: "message-deadbeef")))) diff --git a/Tests/DemoMiddlewareTests/DemoMiddlewareTests.swift b/Tests/DemoMiddlewareTests/DemoMiddlewareTests.swift index 147c27a5..421b9873 100644 --- a/Tests/DemoMiddlewareTests/DemoMiddlewareTests.swift +++ b/Tests/DemoMiddlewareTests/DemoMiddlewareTests.swift @@ -17,7 +17,7 @@ import XCTest class DemoMiddlewareTests: XCTestCase { func testBasics() throws { - var environment = ServerEnvironment.failing + var environment = ServerEnvironment.unimplemented environment.database.fetchLeaderboardSummary = { request in switch request.timeScope { case .allTime: diff --git a/Tests/GameCoreTests/GameCoreTests.swift b/Tests/GameCoreTests/GameCoreTests.swift index e0b2fd61..0ed5980f 100644 --- a/Tests/GameCoreTests/GameCoreTests.swift +++ b/Tests/GameCoreTests/GameCoreTests.swift @@ -3,17 +3,16 @@ import ComposableArchitecture import GameCore import XCTest +@MainActor class GameCoreTests: XCTestCase { - func testForfeitTurnBasedGame() { - var didEndMatchInTurn = false + func testForfeitTurnBasedGame() async { + let didEndMatchInTurn = ActorIsolated(false) - var environment = GameEnvironment.failing - environment.audioPlayer.stop = { _ in .none } - environment.database.saveGame = { _ in .none } + var environment = GameEnvironment.unimplemented + environment.audioPlayer.stop = { _ in } environment.gameCenter.localPlayer.localPlayer = { .authenticated } environment.gameCenter.turnBasedMatch.endMatchInTurn = { _ in - didEndMatchInTurn = true - return .none + await didEndMatchInTurn.setValue(true) } var gameState = GameState(inProgressGame: .mock) @@ -36,7 +35,7 @@ class GameCoreTests: XCTestCase { environment: environment ) - store.send(.forfeitGameButtonTapped) { + await store.send(.forfeitGameButtonTapped) { $0.alert = .init( title: .init("Are you sure?"), message: .init( @@ -50,7 +49,7 @@ class GameCoreTests: XCTestCase { ) } - store.send(.alert(.forfeitButtonTapped)) { + await store.send(.alert(.forfeitButtonTapped)) { $0.alert = nil $0.gameOver = .init( completedGame: .init(gameState: gameState), @@ -59,6 +58,7 @@ class GameCoreTests: XCTestCase { ) } - XCTAssertNoDifference(didEndMatchInTurn, true) + await didEndMatchInTurn.withValue { XCTAssertNoDifference($0, true) } + await store.finish() } } diff --git a/Tests/GameFeatureTests/DailyChallengeTests.swift b/Tests/GameFeatureTests/DailyChallengeTests.swift index 33ab4e31..2be7fe28 100644 --- a/Tests/GameFeatureTests/DailyChallengeTests.swift +++ b/Tests/GameFeatureTests/DailyChallengeTests.swift @@ -10,8 +10,9 @@ import XCTest @testable import GameFeature +@MainActor class DailyChallengeTests: XCTestCase { - func testLeaveTimedDailyChallenge() { + func testLeaveTimedDailyChallenge() async { let move = Move( playedAt: .mock, playerIndex: nil, @@ -24,16 +25,12 @@ class DailyChallengeTests: XCTestCase { ]) ) - var didSave = false + let didSave = ActorIsolated(false) - let environment = update(GameEnvironment.failing) { - $0.audioPlayer.play = { _ in .none } - $0.audioPlayer.stop = { _ in .none } - $0.database.saveGame = { _ in - didSave = true - return .none - } - $0.fileClient.load = { _ in .none } + let environment = update(GameEnvironment.unimplemented) { + $0.audioPlayer.stop = { _ in } + $0.database.saveGame = { _ in await didSave.setValue(true) } + $0.fileClient.load = { @Sendable _ in try await Task.never() } $0.gameCenter.localPlayer.localPlayer = { .authenticated } $0.mainQueue = .immediate } @@ -54,7 +51,7 @@ class DailyChallengeTests: XCTestCase { environment: environment ) - store.send(.game(.endGameButtonTapped)) { + await store.send(.game(.endGameButtonTapped)) { try XCTUnwrap(&$0.game) { $0.gameOver = GameOverState( completedGame: CompletedGame(gameState: $0), @@ -63,10 +60,10 @@ class DailyChallengeTests: XCTestCase { } } - XCTAssertNoDifference(didSave, true) + await didSave.withValue { XCTAssert($0) } } - func testLeaveUnlimitedDailyChallenge() { + func testLeaveUnlimitedDailyChallenge() async { let move = Move( playedAt: .mock, playerIndex: nil, @@ -79,15 +76,12 @@ class DailyChallengeTests: XCTestCase { ]) ) - var didSave = false - let environment = update(GameEnvironment.failing) { - $0.audioPlayer.play = { _ in .none } - $0.audioPlayer.stop = { _ in .none } - $0.database.saveGame = { _ in - didSave = true - return .none - } - $0.fileClient.load = { _ in .none } + let didSave = ActorIsolated(false) + + let environment = update(GameEnvironment.unimplemented) { + $0.audioPlayer.stop = { _ in } + $0.database.saveGame = { _ in await didSave.setValue(true) } + $0.fileClient.load = { @Sendable _ in try await Task.never() } $0.gameCenter.localPlayer.localPlayer = { .authenticated } $0.mainQueue = .immediate } @@ -108,7 +102,7 @@ class DailyChallengeTests: XCTestCase { environment: environment ) - store.send(.game(.endGameButtonTapped)) { + await store.send(.game(.endGameButtonTapped)) { try XCTUnwrap(&$0.game) { $0.gameOver = GameOverState( completedGame: CompletedGame(gameState: $0), @@ -116,7 +110,8 @@ class DailyChallengeTests: XCTestCase { ) } } + .finish() - XCTAssertNoDifference(didSave, true) + await didSave.withValue { XCTAssert($0) } } } diff --git a/Tests/GameFeatureTests/GameFeatureTests.swift b/Tests/GameFeatureTests/GameFeatureTests.swift index fd2baefe..4cd19dd0 100644 --- a/Tests/GameFeatureTests/GameFeatureTests.swift +++ b/Tests/GameFeatureTests/GameFeatureTests.swift @@ -12,13 +12,14 @@ import XCTest @testable import GameFeature +@MainActor class GameFeatureTests: XCTestCase { let mainRunLoop = RunLoop.test - func testRemoveCubeMove() { - let environment = update(GameEnvironment.failing) { - $0.audioPlayer.play = { _ in .none } - $0.fileClient.load = { _ in .none } + func testRemoveCubeMove() async { + let environment = update(GameEnvironment.unimplemented) { + $0.audioPlayer.play = { _ in } + $0.fileClient.load = { @Sendable _ in try await Task.never() } $0.gameCenter.localPlayer.localPlayer = { .authenticated } $0.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler() } @@ -51,8 +52,8 @@ class GameFeatureTests: XCTestCase { environment: environment ) - store.send(.game(.doubleTap(index: .zero))) - store.receive(.game(.confirmRemoveCube(.zero))) { + await store.send(.game(.doubleTap(index: .zero))) + await store.receive(.game(.confirmRemoveCube(.zero))) { $0.game?.cubes.0.0.0.wasRemoved = true $0.game?.moves = [ .init( @@ -64,11 +65,12 @@ class GameFeatureTests: XCTestCase { ) ] } + await store.finish() } - func testDoubleTapRemoveCube_MultipleSelectedFaces() { - let environment = update(GameEnvironment.failing) { - $0.fileClient.load = { _ in .none } + func testDoubleTapRemoveCube_MultipleSelectedFaces() async { + let environment = update(GameEnvironment.unimplemented) { + $0.fileClient.load = { @Sendable _ in try await Task.never() } $0.gameCenter.localPlayer.localPlayer = { .authenticated } $0.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler() } @@ -94,7 +96,7 @@ class GameFeatureTests: XCTestCase { environment: environment ) - store.send(.game(.doubleTap(index: .zero))) + await store.send(.game(.doubleTap(index: .zero))) } func testIsYourTurn() { diff --git a/Tests/GameOverFeatureIntegrationTests/GameOverFeatureIntegrationTests.swift b/Tests/GameOverFeatureIntegrationTests/GameOverFeatureIntegrationTests.swift index 9148aa6e..e6e25173 100644 --- a/Tests/GameOverFeatureIntegrationTests/GameOverFeatureIntegrationTests.swift +++ b/Tests/GameOverFeatureIntegrationTests/GameOverFeatureIntegrationTests.swift @@ -5,14 +5,15 @@ import SharedModels import SiteMiddleware import XCTest +@MainActor class GameOverFeatureIntegrationTests: XCTestCase { - func testSubmitSoloScore() { + func testSubmitSoloScore() async { let ranks: [TimeScope: LeaderboardScoreResult.Rank] = [ .allTime: .init(outOf: 10_000, rank: 1_000), .lastWeek: .init(outOf: 1_000, rank: 100), .lastDay: .init(outOf: 100, rank: 10), ] - var serverEnvironment = ServerEnvironment.failing + var serverEnvironment = ServerEnvironment.unimplemented serverEnvironment.database.fetchPlayerByAccessToken = { _ in .init(value: .blob) } @@ -38,16 +39,18 @@ class GameOverFeatureIntegrationTests: XCTestCase { serverEnvironment.dictionary.contains = { _, _ in true } serverEnvironment.router = .test - var environment = GameOverEnvironment.failing + var environment = GameOverEnvironment.unimplemented environment.audioPlayer = .noop environment.apiClient = .init( middleware: siteMiddleware(environment: serverEnvironment), router: .test ) - environment.database.playedGamesCount = { _ in .init(value: 0) } + environment.database.playedGamesCount = { _ in 0 } environment.mainRunLoop = .immediate environment.serverConfig.config = { .init() } - environment.userNotifications.getNotificationSettings = .none + environment.userNotifications.getNotificationSettings = { + (try? await Task.never()) ?? .init(authorizationStatus: .notDetermined) + } let store = TestStore( initialState: GameOverState( @@ -58,14 +61,14 @@ class GameOverFeatureIntegrationTests: XCTestCase { environment: environment ) - store.send(.onAppear) - - store.receive(.delayedOnAppear) { - $0.isViewEnabled = true - } + let task = await store.send(.task) - store.receive(.submitGameResponse(.success(.solo(.init(ranks: ranks))))) { + await store.receive(.submitGameResponse(.success(.solo(.init(ranks: ranks))))) { $0.summary = .leaderboard(ranks) } + await store.receive(.delayedOnAppear) { + $0.isViewEnabled = true + } + await task.cancel() } } diff --git a/Tests/GameOverFeatureTests/GameOverFeatureTests.swift b/Tests/GameOverFeatureTests/GameOverFeatureTests.swift index 8c4689e7..a921673f 100644 --- a/Tests/GameOverFeatureTests/GameOverFeatureTests.swift +++ b/Tests/GameOverFeatureTests/GameOverFeatureTests.swift @@ -1,3 +1,4 @@ +import ApiClient import CasePaths import ComposableArchitecture import GameOverFeature @@ -9,11 +10,12 @@ import XCTest @testable import LocalDatabaseClient @testable import UserDefaultsClient +@MainActor class GameOverFeatureTests: XCTestCase { let mainRunLoop = RunLoop.test - func testSubmitLeaderboardScore() throws { - var environment = GameOverEnvironment.failing + func testSubmitLeaderboardScore() async throws { + var environment = GameOverEnvironment.unimplemented environment.audioPlayer = .noop environment.apiClient.currentPlayer = { .init(appleReceipt: .mock, player: .blob) } environment.apiClient.override( @@ -25,20 +27,24 @@ class GameOverFeatureTests: XCTestCase { ) ) ), - withResponse: .ok([ - "solo": [ - "ranks": [ - "lastDay": LeaderboardScoreResult.Rank(outOf: 100, rank: 1), - "lastWeek": .init(outOf: 1000, rank: 10), - "allTime": .init(outOf: 10000, rank: 100), + withResponse: { + try await OK([ + "solo": [ + "ranks": [ + "lastDay": LeaderboardScoreResult.Rank(outOf: 100, rank: 1), + "lastWeek": .init(outOf: 1000, rank: 10), + "allTime": .init(outOf: 10000, rank: 100), + ] ] - ] - ]) + ]) + } ) - environment.database.playedGamesCount = { _ in .init(value: 10) } + environment.database.playedGamesCount = { _ in 0 } environment.mainRunLoop = .immediate environment.serverConfig.config = { .init() } - environment.userNotifications.getNotificationSettings = .none + environment.userNotifications.getNotificationSettings = { + (try? await Task.never()) ?? .init(authorizationStatus: .notDetermined) + } let store = TestStore( initialState: GameOverState( @@ -57,11 +63,8 @@ class GameOverFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) - store.receive(.delayedOnAppear) { - $0.isViewEnabled = true - } - store.receive( + let task = await store.send(.task) + await store.receive( .submitGameResponse( .success( .solo( @@ -80,9 +83,13 @@ class GameOverFeatureTests: XCTestCase { .allTime: .init(outOf: 10000, rank: 100), ]) } + await store.receive(.delayedOnAppear) { + $0.isViewEnabled = true + } + await task.cancel() } - func testSubmitDailyChallenge() throws { + func testSubmitDailyChallenge() async throws { let dailyChallengeResponses = [ FetchTodaysDailyChallengeResponse( dailyChallenge: .init( @@ -104,7 +111,7 @@ class GameOverFeatureTests: XCTestCase { ), ] - var environment = GameOverEnvironment.failing + var environment = GameOverEnvironment.unimplemented environment.audioPlayer = .noop environment.apiClient.currentPlayer = { .init(appleReceipt: .mock, player: .blob) } environment.apiClient.override( @@ -116,37 +123,41 @@ class GameOverFeatureTests: XCTestCase { ) ) ), - withResponse: .ok([ - "dailyChallenge": ["rank": 2, "outOf": 100, "score": 1000, "started": true] - ]) + withResponse: { + try await OK(["dailyChallenge": ["rank": 2, "outOf": 100, "score": 1000, "started": true]]) + } ) environment.apiClient.override( route: .dailyChallenge(.today(language: .en)), - withResponse: .ok([ - [ - "dailyChallenge": [ - "endsAt": 1_234_567_890, - "gameMode": "timed", - "id": UUID.dailyChallengeId.uuidString, - "language": "en", + withResponse: { + try await OK([ + [ + "dailyChallenge": [ + "endsAt": 1_234_567_890, + "gameMode": "timed", + "id": UUID.dailyChallengeId.uuidString, + "language": "en", + ], + "yourResult": ["outOf": 42, "rank": 1, "score": 3600, "started": true], ], - "yourResult": ["outOf": 42, "rank": 1, "score": 3600, "started": true], - ], - [ - "dailyChallenge": [ - "endsAt": 1_234_567_890, - "gameMode": "unlimited", - "id": UUID.dailyChallengeId.uuidString, - "language": "en", + [ + "dailyChallenge": [ + "endsAt": 1_234_567_890, + "gameMode": "unlimited", + "id": UUID.dailyChallengeId.uuidString, + "language": "en", + ], + "yourResult": ["outOf": 42, "started": false], ], - "yourResult": ["outOf": 42, "started": false], - ], - ]) + ]) + } ) - environment.database.playedGamesCount = { _ in .init(value: 10) } + environment.database.playedGamesCount = { _ in 0 } environment.mainRunLoop = .immediate environment.serverConfig.config = { .init() } - environment.userNotifications.getNotificationSettings = .none + environment.userNotifications.getNotificationSettings = { + (try? await Task.never()) ?? .init(authorizationStatus: .notDetermined) + } let store = TestStore( initialState: GameOverState( @@ -165,28 +176,23 @@ class GameOverFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) - store.receive(.delayedOnAppear) { $0.isViewEnabled = true } - store.receive( + let task = await store.send(.task) + await store.receive( .submitGameResponse( - .success( - .dailyChallenge( - .init(outOf: 100, rank: 2, score: 1000, started: true) - ) - ) + .success(.dailyChallenge(.init(outOf: 100, rank: 2, score: 1000, started: true))) ) ) { $0.summary = .dailyChallenge(.init(outOf: 100, rank: 2, score: 1000, started: true)) } - store.receive( - .dailyChallengeResponse(.success(dailyChallengeResponses)) - ) { + await store.receive(.delayedOnAppear) { $0.isViewEnabled = true } + await store.receive(.dailyChallengeResponse(.success(dailyChallengeResponses))) { $0.dailyChallenges = dailyChallengeResponses } + await task.cancel() } - func testTurnBased_TrackLeaderboards() throws { - var environment = GameOverEnvironment.failing + func testTurnBased_TrackLeaderboards() async throws { + var environment = GameOverEnvironment.unimplemented environment.audioPlayer = .noop environment.apiClient.currentPlayer = { .init(appleReceipt: .mock, player: .blob) } environment.apiClient.override( @@ -205,22 +211,14 @@ class GameOverFeatureTests: XCTestCase { ) ) ), - withResponse: .ok(["turnBased": true]) - ) - environment.database.playedGamesCount = { _ in .init(value: 10) } - environment.database.fetchStats = .init( - value: .init( - averageWordLength: nil, - gamesPlayed: 1, - highestScoringWord: nil, - longestWord: nil, - secondsPlayed: 1, - wordsFound: 1 - ) + withResponse: { try await OK(["turnBased": true]) } ) + environment.database.playedGamesCount = { _ in 10 } environment.mainRunLoop = .immediate environment.serverConfig.config = { .init() } - environment.userNotifications.getNotificationSettings = .none + environment.userNotifications.getNotificationSettings = { + (try? await Task.never()) ?? .init(authorizationStatus: .notDetermined) + } let store = TestStore( initialState: GameOverState( @@ -240,14 +238,15 @@ class GameOverFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) - store.receive(.delayedOnAppear) { $0.isViewEnabled = true } - store.receive(.submitGameResponse(.success(.turnBased))) + let task = await store.send(.task) + await store.receive(.submitGameResponse(.success(.turnBased))) + await store.receive(.delayedOnAppear) { $0.isViewEnabled = true } + await task.cancel() } - func testRequestReviewOnClose() { - var lastReviewRequestTimeIntervalSet: Double? - var requestReviewCount = 0 + func testRequestReviewOnClose() async { + let lastReviewRequestTimeIntervalSet = ActorIsolated(nil) + let requestReviewCount = ActorIsolated(0) let completedGame = CompletedGame( cubes: .mock, @@ -260,9 +259,9 @@ class GameOverFeatureTests: XCTestCase { secondsPlayed: 0 ) - var environment = GameOverEnvironment.failing - environment.database.fetchStats = .init( - value: .init( + var environment = GameOverEnvironment.unimplemented + environment.database.fetchStats = { + LocalDatabaseClient.Stats( averageWordLength: nil, gamesPlayed: 1, highestScoringWord: nil, @@ -270,20 +269,17 @@ class GameOverFeatureTests: XCTestCase { secondsPlayed: 1, wordsFound: 1 ) - ) + } environment.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler() environment.storeKit.requestReview = { - .fireAndForget { requestReviewCount += 1 } + await requestReviewCount.withValue { $0 += 1 } } environment.userDefaults.override(double: 0, forKey: "last-review-request-timeinterval") environment.userDefaults.setDouble = { double, key in - .fireAndForget { - if key == "last-review-request-timeinterval" { - lastReviewRequestTimeIntervalSet = double - } + if key == "last-review-request-timeinterval" { + await lastReviewRequestTimeIntervalSet.setValue(double) } } - environment.userNotifications.getNotificationSettings = .none let store = TestStore( initialState: GameOverState(completedGame: completedGame, isDemo: false, isViewEnabled: true), @@ -292,15 +288,15 @@ class GameOverFeatureTests: XCTestCase { ) // Assert that the first time game over appears we do not request review - store.send(.closeButtonTapped) - store.receive(.delegate(.close)) - self.mainRunLoop.advance() - XCTAssertNoDifference(requestReviewCount, 0) - XCTAssertNoDifference(lastReviewRequestTimeIntervalSet, nil) + await store.send(.closeButtonTapped) + await store.receive(.delegate(.close)) + await self.mainRunLoop.advance() + await requestReviewCount.withValue { XCTAssertNoDifference($0, 0) } + await lastReviewRequestTimeIntervalSet.withValue { XCTAssertNoDifference($0, nil) } // Assert that once the player plays enough games then a review request is made - store.environment.database.fetchStats = .init( - value: .init( + store.environment.database.fetchStats = { + .init( averageWordLength: nil, gamesPlayed: 3, highestScoringWord: nil, @@ -308,23 +304,21 @@ class GameOverFeatureTests: XCTestCase { secondsPlayed: 1, wordsFound: 1 ) - ) - store.send(.closeButtonTapped) - store.receive(.delegate(.close)) - self.mainRunLoop.advance() - XCTAssertNoDifference(requestReviewCount, 1) - XCTAssertNoDifference(lastReviewRequestTimeIntervalSet, 0) + } + await store.send(.closeButtonTapped).finish() + await store.receive(.delegate(.close)) + await requestReviewCount.withValue { XCTAssertNoDifference($0, 1) } + await lastReviewRequestTimeIntervalSet.withValue { XCTAssertNoDifference($0, 0) } // Assert that when more than a week of time passes we again request review - self.mainRunLoop.advance(by: .seconds(60 * 60 * 24 * 7)) - store.send(.closeButtonTapped) - store.receive(.delegate(.close)) - self.mainRunLoop.advance() - XCTAssertNoDifference(requestReviewCount, 2) - XCTAssertNoDifference(lastReviewRequestTimeIntervalSet, 60 * 60 * 24 * 7) + await self.mainRunLoop.advance(by: .seconds(60 * 60 * 24 * 7)) + await store.send(.closeButtonTapped).finish() + await store.receive(.delegate(.close)) + await requestReviewCount.withValue { XCTAssertNoDifference($0, 2) } + await lastReviewRequestTimeIntervalSet.withValue { XCTAssertNoDifference($0, 60 * 60 * 24 * 7) } } - func testAutoCloseWhenNoWordsPlayed() throws { + func testAutoCloseWhenNoWordsPlayed() async throws { let store = TestStore( initialState: GameOverState( completedGame: .init( @@ -339,31 +333,14 @@ class GameOverFeatureTests: XCTestCase { isDemo: false ), reducer: gameOverReducer, - environment: .failing + environment: .unimplemented ) - store.send(.onAppear) - store.receive(.delegate(.close)) + await store.send(.task) + await store.receive(.delegate(.close)) } - func testShowUpgradeInterstitial() { - var environment = GameOverEnvironment.failing - environment.audioPlayer = .noop - environment.apiClient.currentPlayer = { .init(appleReceipt: nil, player: .blob) } - environment.apiClient.override( - routeCase: /ServerRoute.Api.Route.games .. /ServerRoute.Api.Route.Games.submit, - withResponse: { _ in .none } - ) - environment.database.playedGamesCount = { _ in .init(value: 6) } - environment.database.fetchStats = .init(value: .init()) - environment.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler() - environment.serverConfig.config = { .init() } - environment.userDefaults.override( - double: self.mainRunLoop.now.date.timeIntervalSince1970, - forKey: "last-review-request-timeinterval" - ) - environment.userNotifications.getNotificationSettings = .none - + func testShowUpgradeInterstitial() async { let store = TestStore( initialState: GameOverState( completedGame: .init( @@ -378,41 +355,28 @@ class GameOverFeatureTests: XCTestCase { isDemo: false ), reducer: gameOverReducer, - environment: environment + environment: .unimplemented ) - store.send(.onAppear) - self.mainRunLoop.advance(by: .seconds(1)) - store.receive(.delayedShowUpgradeInterstitial) { - $0.upgradeInterstitial = .init() + store.environment.audioPlayer = .noop + store.environment.apiClient.currentPlayer = { .init(appleReceipt: nil, player: .blob) } + store.environment.apiClient.apiRequest = { @Sendable _ in try await Task.never() } + store.environment.database.playedGamesCount = { _ in 6 } + store.environment.mainRunLoop = .immediate + store.environment.serverConfig.config = { .init() } + store.environment.userNotifications.getNotificationSettings = { + (try? await Task.never()) ?? .init(authorizationStatus: .notDetermined) } - self.mainRunLoop.advance(by: .seconds(1)) - store.receive(.delayedOnAppear) { $0.isViewEnabled = true } - } - func testSkipUpgradeIfLessThan10GamesPlayed() { - var environment = GameOverEnvironment.failing - environment.audioPlayer = .noop - environment.apiClient.currentPlayer = { .init(appleReceipt: nil, player: .blob) } - environment.apiClient.apiRequest = { route in - switch route { - case .games(.submit): - return .none - default: - XCTFail("Unhandled route: \(route)") - return .none - } + let task = await store.send(.task) + await store.receive(.delayedShowUpgradeInterstitial) { + $0.upgradeInterstitial = .init() } - environment.database.playedGamesCount = { _ in .init(value: 5) } - environment.database.fetchStats = .init(value: .init()) - environment.mainRunLoop = .immediate - environment.serverConfig.config = { .init() } - environment.userDefaults.override( - double: self.mainRunLoop.now.date.timeIntervalSince1970, - forKey: "last-review-request-timeinterval" - ) - environment.userNotifications.getNotificationSettings = .none + await store.receive(.delayedOnAppear) { $0.isViewEnabled = true } + await task.cancel() + } + func testSkipUpgradeIfLessThan6GamesPlayed() async { let store = TestStore( initialState: GameOverState( completedGame: .init( @@ -427,10 +391,21 @@ class GameOverFeatureTests: XCTestCase { isDemo: false ), reducer: gameOverReducer, - environment: environment + environment: .unimplemented ) - store.send(.onAppear) - store.receive(.delayedOnAppear) { $0.isViewEnabled = true } + store.environment.audioPlayer = .noop + store.environment.apiClient.currentPlayer = { .init(appleReceipt: nil, player: .blob) } + store.environment.apiClient.apiRequest = { @Sendable _ in try await Task.never() } + store.environment.database.playedGamesCount = { _ in 5 } + store.environment.mainRunLoop = .immediate + store.environment.serverConfig.config = { .init() } + store.environment.userNotifications.getNotificationSettings = { + (try? await Task.never()) ?? .init(authorizationStatus: .notDetermined) + } + + let task = await store.send(.task) + await store.receive(.delayedOnAppear) { $0.isViewEnabled = true } + await task.cancel() } } diff --git a/Tests/GameOverFeatureTests/__Snapshots__/GameOverViewTests/testDailyChallenge.1.png b/Tests/GameOverFeatureTests/__Snapshots__/GameOverViewTests/testDailyChallenge.1.png index dcd633df..dde3ff84 100644 --- a/Tests/GameOverFeatureTests/__Snapshots__/GameOverViewTests/testDailyChallenge.1.png +++ b/Tests/GameOverFeatureTests/__Snapshots__/GameOverViewTests/testDailyChallenge.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b624718d26512f0604811ad7a93bb00c55156d7c7870a7f177023371540fabd4 -size 192322 +oid sha256:de65d0320dcaadc06ced25057a2c5b59251cecef871017a31e8abe19aae6cead +size 192307 diff --git a/Tests/GameOverFeatureTests/__Snapshots__/GameOverViewTests/testSolo.1.png b/Tests/GameOverFeatureTests/__Snapshots__/GameOverViewTests/testSolo.1.png index c656b080..a98b170e 100644 --- a/Tests/GameOverFeatureTests/__Snapshots__/GameOverViewTests/testSolo.1.png +++ b/Tests/GameOverFeatureTests/__Snapshots__/GameOverViewTests/testSolo.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:70bb5ca8fada7f71a4761de0f87efaec5512642806aca8fc5b8c8879f2760991 -size 186857 +oid sha256:b56bd38cf6afd1104fcd63b2b66e03021d3c9df8fad11988ffb9580aefd6dca0 +size 186913 diff --git a/Tests/GameOverFeatureTests/__Snapshots__/GameOverViewTests/testTurnBased.1.png b/Tests/GameOverFeatureTests/__Snapshots__/GameOverViewTests/testTurnBased.1.png index 5542fefd..9a810a77 100644 --- a/Tests/GameOverFeatureTests/__Snapshots__/GameOverViewTests/testTurnBased.1.png +++ b/Tests/GameOverFeatureTests/__Snapshots__/GameOverViewTests/testTurnBased.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ab1c871222dbdfbcc14a00cbedcec1aac0fc9fc174e335a4c4617cad8b4fc77f -size 330691 +oid sha256:45df50800aa23d76dc4225700545d33178012b6ec916f6fa2080103bc0563a90 +size 327247 diff --git a/Tests/HomeFeatureTests/__Snapshots__/HomeViewTests/testActiveGames_DailyChallenge_Solo.1.png b/Tests/HomeFeatureTests/__Snapshots__/HomeViewTests/testActiveGames_DailyChallenge_Solo.1.png index f8cab7e1..0005dfbb 100644 --- a/Tests/HomeFeatureTests/__Snapshots__/HomeViewTests/testActiveGames_DailyChallenge_Solo.1.png +++ b/Tests/HomeFeatureTests/__Snapshots__/HomeViewTests/testActiveGames_DailyChallenge_Solo.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4c60c4911b7c88905e9c1a47f2f708b9171bb33a233b9d8e5acee8252bf88cb4 -size 664156 +oid sha256:5ba868b4674ea88e2209c1e57f486dd9f1e0a7be9dc0f1286fec3d945c945e1c +size 663240 diff --git a/Tests/HomeFeatureTests/__Snapshots__/HomeViewTests/testActiveGames_Multiplayer.1.png b/Tests/HomeFeatureTests/__Snapshots__/HomeViewTests/testActiveGames_Multiplayer.1.png index 0f741185..10cef4f6 100644 --- a/Tests/HomeFeatureTests/__Snapshots__/HomeViewTests/testActiveGames_Multiplayer.1.png +++ b/Tests/HomeFeatureTests/__Snapshots__/HomeViewTests/testActiveGames_Multiplayer.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8c997caac850c5949e4678b72725e0541073aaa86cee96e486da96d1da6aecb7 -size 669508 +oid sha256:670dbe228f33d16fc7038c45b4cdc9ec4d64503909ac05860985c394bbb86bbd +size 669056 diff --git a/Tests/HomeFeatureTests/__Snapshots__/HomeViewTests/testActiveGames_StaleGame.1.png b/Tests/HomeFeatureTests/__Snapshots__/HomeViewTests/testActiveGames_StaleGame.1.png index 40218509..d021633a 100644 --- a/Tests/HomeFeatureTests/__Snapshots__/HomeViewTests/testActiveGames_StaleGame.1.png +++ b/Tests/HomeFeatureTests/__Snapshots__/HomeViewTests/testActiveGames_StaleGame.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bfa995fbffb0d37c9d5da7d906a3af68436e4d83c276c63e41cdc55c3ae38618 -size 690601 +oid sha256:1d4805b09f2a6dea92568968f921ba08a693539ea52a393167eba11b6620eed8 +size 688166 diff --git a/Tests/HomeFeatureTests/__Snapshots__/HomeViewTests/testBasics.1.png b/Tests/HomeFeatureTests/__Snapshots__/HomeViewTests/testBasics.1.png index c1d9e363..771865c3 100644 --- a/Tests/HomeFeatureTests/__Snapshots__/HomeViewTests/testBasics.1.png +++ b/Tests/HomeFeatureTests/__Snapshots__/HomeViewTests/testBasics.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d01fd9b29ea7a5cd3fb1e280dd42d7f967d30deea52464e35cb068051b638656 -size 759543 +oid sha256:5b1a19461f37567e6b2976d9c198f19be3d1149343964397acbcd18a9c901313 +size 760387 diff --git a/Tests/LeaderboardFeatureTests/LeaderboardFeatureIntegrationTests.swift b/Tests/LeaderboardFeatureTests/LeaderboardFeatureIntegrationTests.swift index af336373..df09fbf5 100644 --- a/Tests/LeaderboardFeatureTests/LeaderboardFeatureIntegrationTests.swift +++ b/Tests/LeaderboardFeatureTests/LeaderboardFeatureIntegrationTests.swift @@ -8,8 +8,9 @@ import XCTest @testable import LeaderboardFeature +@MainActor class LeaderboardFeatureIntegrationTests: XCTestCase { - func testSoloIntegrationWithLeaderboardResults() { + func testSoloIntegrationWithLeaderboardResults() async { let fetchLeaderboardsEntries = [ FetchLeaderboardResponse.Entry( id: .init(rawValue: .deadbeef), @@ -28,7 +29,7 @@ class LeaderboardFeatureIntegrationTests: XCTestCase { ] ) - let siteEnvironment = update(ServerEnvironment.failing) { + let siteEnvironment = update(ServerEnvironment.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(.blob) } $0.database.fetchRankedLeaderboardScores = { _ in pure(fetchLeaderboardsEntries) @@ -36,7 +37,7 @@ class LeaderboardFeatureIntegrationTests: XCTestCase { } let middleware = siteMiddleware(environment: siteEnvironment) - let leaderboardEnvironment = update(LeaderboardEnvironment.failing) { + let leaderboardEnvironment = update(LeaderboardEnvironment.unimplemented) { $0.apiClient = ApiClient(middleware: middleware, router: .test) $0.mainQueue = .immediate } @@ -47,17 +48,17 @@ class LeaderboardFeatureIntegrationTests: XCTestCase { environment: leaderboardEnvironment ) - store.send(.solo(.onAppear)) { + await store.send(.solo(.task)) { $0.solo.isLoading = true $0.solo.resultEnvelope = .placeholder } - store.receive(.solo(.resultsResponse(.success(results)))) { + await store.receive(.solo(.resultsResponse(.success(results)))) { $0.solo.isLoading = false $0.solo.resultEnvelope = results } } - func testVocabIntegrationWithLeaderboardResults() { + func testVocabIntegrationWithLeaderboardResults() async { let fetchVocabEntries = [ FetchVocabLeaderboardResponse.Entry.init( denseRank: 1, @@ -87,7 +88,7 @@ class LeaderboardFeatureIntegrationTests: XCTestCase { ] ) - let siteEnvironment = update(ServerEnvironment.failing) { + let siteEnvironment = update(ServerEnvironment.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(.blob) } $0.database.fetchVocabLeaderboard = { _, _, _ in pure(fetchVocabEntries) @@ -95,7 +96,7 @@ class LeaderboardFeatureIntegrationTests: XCTestCase { } let middleware = siteMiddleware(environment: siteEnvironment) - let leaderboardEnvironment = update(LeaderboardEnvironment.failing) { + let leaderboardEnvironment = update(LeaderboardEnvironment.unimplemented) { $0.apiClient = ApiClient(middleware: middleware, router: .test) $0.mainQueue = .immediate } @@ -106,11 +107,11 @@ class LeaderboardFeatureIntegrationTests: XCTestCase { environment: leaderboardEnvironment ) - store.send(.vocab(.onAppear)) { + await store.send(.vocab(.task)) { $0.vocab.isLoading = true $0.vocab.resultEnvelope = .placeholder } - store.receive(.vocab(.resultsResponse(.success(results)))) { + await store.receive(.vocab(.resultsResponse(.success(results)))) { $0.vocab.isLoading = false $0.vocab.resultEnvelope = results } diff --git a/Tests/LeaderboardFeatureTests/LeaderboardFeatureTests.swift b/Tests/LeaderboardFeatureTests/LeaderboardFeatureTests.swift index ba062db3..e4e5dccd 100644 --- a/Tests/LeaderboardFeatureTests/LeaderboardFeatureTests.swift +++ b/Tests/LeaderboardFeatureTests/LeaderboardFeatureTests.swift @@ -9,49 +9,52 @@ import XCTest @testable import LeaderboardFeature @testable import SharedModels +@MainActor class LeaderboardFeatureTests: XCTestCase { - - func testScopeSwitcher() { + func testScopeSwitcher() async { let store = TestStore( initialState: .init(isHapticsEnabled: false, settings: .init()), reducer: leaderboardReducer, - environment: .failing + environment: .unimplemented ) - store.send(.scopeTapped(.vocab)) { + await store.send(.scopeTapped(.vocab)) { $0.scope = .vocab } - store.send(.scopeTapped(.games)) { + await store.send(.scopeTapped(.games)) { $0.scope = .games } } - func testTimeScopeSynchronization() { + func testTimeScopeSynchronization() async { let store = TestStore( initialState: .init(isHapticsEnabled: false, settings: .init()), reducer: leaderboardReducer, environment: .init( - apiClient: .noop, + apiClient: .unimplemented, audioPlayer: .noop, feedbackGenerator: .noop, lowPowerMode: .false, mainQueue: .immediate ) ) + store.environment.apiClient.apiRequest = { @Sendable _ in try await Task.never() } - store.send(.solo(.timeScopeChanged(.lastDay))) { + let task1 = await store.send(.solo(.timeScopeChanged(.lastDay))) { $0.solo.timeScope = .lastDay $0.solo.isLoading = true $0.vocab.timeScope = .lastDay } - store.send(.vocab(.timeScopeChanged(.allTime))) { + let task2 = await store.send(.vocab(.timeScopeChanged(.allTime))) { $0.solo.timeScope = .allTime $0.vocab.timeScope = .allTime $0.vocab.isLoading = true } + await task1.cancel() + await task2.cancel() } - func testCubePreview() { + func testCubePreview() async { let wordId = Word.Id(rawValue: UUID(uuidString: "00000000-0000-0000-0000-00000000304d")!) let vocabEntry = FetchVocabLeaderboardResponse.Entry( denseRank: 1, @@ -87,7 +90,7 @@ class LeaderboardFeatureTests: XCTestCase { ] ) - let siteEnvironment = update(ServerEnvironment.failing) { + let siteEnvironment = update(ServerEnvironment.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(.blob) } $0.database.fetchVocabLeaderboard = { _, _, _ in pure([vocabEntry]) @@ -99,7 +102,7 @@ class LeaderboardFeatureTests: XCTestCase { } let middleware = siteMiddleware(environment: siteEnvironment) - let leaderboardEnvironment = update(LeaderboardEnvironment.failing) { + let leaderboardEnvironment = update(LeaderboardEnvironment.unimplemented) { $0.apiClient = ApiClient(middleware: middleware, router: .test) $0.mainQueue = .immediate } @@ -114,16 +117,16 @@ class LeaderboardFeatureTests: XCTestCase { environment: leaderboardEnvironment ) - store.send(.vocab(.onAppear)) { + await store.send(.vocab(.task)) { $0.vocab.isLoading = true $0.vocab.resultEnvelope = .placeholder } - store.receive(.vocab(.resultsResponse(.success(resultsEnvelope)))) { + await store.receive(.vocab(.resultsResponse(.success(resultsEnvelope)))) { $0.vocab.isLoading = false $0.vocab.resultEnvelope = resultsEnvelope } - store.send(.vocab(.tappedRow(id: wordId.rawValue))) - store.receive(.fetchWordResponse(.success(fetchWordResponse))) { + await store.send(.vocab(.tappedRow(id: wordId.rawValue))) + await store.receive(.fetchWordResponse(.success(fetchWordResponse))) { $0.cubePreview = .init( cubes: .mock, isAnimationReduced: false, @@ -134,7 +137,7 @@ class LeaderboardFeatureTests: XCTestCase { settings: .init() ) } - store.send(.dismissCubePreview) { + await store.send(.dismissCubePreview) { $0.cubePreview = nil } } diff --git a/Tests/LeaderboardFeatureTests/LeaderboardResultsTests.swift b/Tests/LeaderboardFeatureTests/LeaderboardResultsTests.swift index 7ce3f5a2..288260b2 100644 --- a/Tests/LeaderboardFeatureTests/LeaderboardResultsTests.swift +++ b/Tests/LeaderboardFeatureTests/LeaderboardResultsTests.swift @@ -9,81 +9,78 @@ import XCTest @testable import LeaderboardFeature +@MainActor class LeaderboardTests: XCTestCase { - func testOnAppear() { + func testOnAppear() async { let store = TestStore( initialState: LeaderboardResultsState(timeScope: TimeScope.lastWeek), reducer: Reducer.leaderboardResultsReducer(), environment: .happyPath ) - store.send(.onAppear) { + await store.send(.task) { $0.isLoading = true $0.resultEnvelope = .placeholder } - store.receive(.resultsResponse(.success(timedResults))) { + await store.receive(.resultsResponse(.success(timedResults))) { $0.isLoading = false $0.resultEnvelope = timedResults } } - func testChangeGameMode() { + func testChangeGameMode() async { let store = TestStore( initialState: LeaderboardResultsState(timeScope: TimeScope.lastWeek), reducer: Reducer.leaderboardResultsReducer(), environment: .happyPath ) - store.send(.gameModeButtonTapped(.unlimited)) { + await store.send(.gameModeButtonTapped(.unlimited)) { $0.gameMode = .unlimited $0.isLoading = true } - store.receive(.resultsResponse(.success(untimedResults))) { + await store.receive(.resultsResponse(.success(untimedResults))) { $0.isLoading = false $0.resultEnvelope = untimedResults } } - func testChangeTimeScope() { + func testChangeTimeScope() async { let store = TestStore( initialState: LeaderboardResultsState(timeScope: TimeScope.lastWeek), reducer: Reducer.leaderboardResultsReducer(), environment: .happyPath ) - store.send(.tappedTimeScopeLabel) { + await store.send(.tappedTimeScopeLabel) { $0.isTimeScopeMenuVisible = true } - store.send(.timeScopeChanged(.lastDay)) { + await store.send(.timeScopeChanged(.lastDay)) { $0.isLoading = true $0.isTimeScopeMenuVisible = false $0.timeScope = .lastDay } - store.receive(.resultsResponse(.success(timedResults))) { + await store.receive(.resultsResponse(.success(timedResults))) { $0.isLoading = false $0.resultEnvelope = timedResults } } - func tetsUnhappyPath() { + func tetsUnhappyPath() async { struct SomeError: Error {} let store = TestStore( initialState: LeaderboardResultsState(timeScope: TimeScope.lastWeek), reducer: Reducer.leaderboardResultsReducer(), environment: LeaderboardResultsEnvironment( - loadResults: { _, _ in - .init(error: .init(error: SomeError())) - }, - mainQueue: .immediate + loadResults: { _, _ in throw SomeError() } ) ) - store.send(.onAppear) { + await store.send(.task) { $0.isLoading = true } - // TODO: why does this pass?? how is the error checked for equality? - store.receive(.resultsResponse(.failure(.init(error: SomeError())))) { + await store.receive(.resultsResponse(.failure(ApiError(error: SomeError())))) { $0.isLoading = false $0.resultEnvelope = nil } @@ -126,12 +123,11 @@ extension LeaderboardResultsEnvironment { loadResults: { gameMode, _ in switch gameMode { case .timed: - return .init(value: timedResults) + return timedResults case .unlimited: - return .init(value: untimedResults) + return untimedResults } - }, - mainQueue: .immediate + } ) } } diff --git a/Tests/LeaderboardMiddlewareIntegrationTests/LeaderboardMiddlewareIntegrationTests.swift b/Tests/LeaderboardMiddlewareIntegrationTests/LeaderboardMiddlewareIntegrationTests.swift index aa69634a..5c24bcc5 100644 --- a/Tests/LeaderboardMiddlewareIntegrationTests/LeaderboardMiddlewareIntegrationTests.swift +++ b/Tests/LeaderboardMiddlewareIntegrationTests/LeaderboardMiddlewareIntegrationTests.swift @@ -66,7 +66,7 @@ class LeaderboardMiddlewareIntegrationTests: XCTestCase { ]) ) ] - var environment = ServerEnvironment.failing + var environment = ServerEnvironment.unimplemented environment.database = self.database environment.dictionary = .everyString environment.router = .test diff --git a/Tests/LeaderboardMiddlewareTests/FetchWeekInReviewMiddlewareTests.swift b/Tests/LeaderboardMiddlewareTests/FetchWeekInReviewMiddlewareTests.swift index 890ff771..f1e9d598 100644 --- a/Tests/LeaderboardMiddlewareTests/FetchWeekInReviewMiddlewareTests.swift +++ b/Tests/LeaderboardMiddlewareTests/FetchWeekInReviewMiddlewareTests.swift @@ -26,7 +26,7 @@ class FetchWeekInReviewMiddlewareTests: XCTestCase { ) let middleware = siteMiddleware( - environment: update(.failing) { + environment: update(.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(.blob) } $0.database.fetchLeaderboardWeeklyRanks = { _, _ in pure([ diff --git a/Tests/LeaderboardMiddlewareTests/LeaderboardMiddlewareTests.swift b/Tests/LeaderboardMiddlewareTests/LeaderboardMiddlewareTests.swift index 2d5185b8..f6c92432 100644 --- a/Tests/LeaderboardMiddlewareTests/LeaderboardMiddlewareTests.swift +++ b/Tests/LeaderboardMiddlewareTests/LeaderboardMiddlewareTests.swift @@ -55,7 +55,7 @@ class LeaderboardMiddlewareTests: XCTestCase { ).base64EncodedString() ] - var environment = ServerEnvironment.failing + var environment = ServerEnvironment.unimplemented environment.database.fetchPlayerByAccessToken = { _ in pure(player) } environment.database.submitLeaderboardScore = { score in XCTAssertNoDifference( @@ -180,7 +180,7 @@ class LeaderboardMiddlewareTests: XCTestCase { ).base64EncodedString() ] - var environment = ServerEnvironment.failing + var environment = ServerEnvironment.unimplemented environment.database.completeDailyChallenge = { _, _ in pure( DailyChallengePlay( @@ -309,7 +309,7 @@ class LeaderboardMiddlewareTests: XCTestCase { ).base64EncodedString() ] - var environment = ServerEnvironment.failing + var environment = ServerEnvironment.unimplemented environment.database.fetchPlayerByAccessToken = { _ in pure(player) } environment.database.fetchSharedGame = { request in pure( @@ -825,7 +825,7 @@ class LeaderboardMiddlewareTests: XCTestCase { ] var scores: [DatabaseClient.SubmitLeaderboardScore] = [] - var environment = ServerEnvironment.failing + var environment = ServerEnvironment.unimplemented environment.database.fetchPlayerByAccessToken = { _ in pure(player) } environment.database.submitLeaderboardScore = { score in scores.append(score) diff --git a/Tests/MultiplayerFeatureTests/MultiplayerFeatureTests.swift b/Tests/MultiplayerFeatureTests/MultiplayerFeatureTests.swift index da3e8d49..76645c59 100644 --- a/Tests/MultiplayerFeatureTests/MultiplayerFeatureTests.swift +++ b/Tests/MultiplayerFeatureTests/MultiplayerFeatureTests.swift @@ -3,16 +3,15 @@ import XCTest @testable import MultiplayerFeature +@MainActor class MultiplayerFeatureTests: XCTestCase { - func testStartGame_GameCenterAuthenticated() { - var didPresentMatchmakerViewController = false + func testStartGame_GameCenterAuthenticated() async { + let didPresentMatchmakerViewController = ActorIsolated(false) - var environment = MultiplayerEnvironment.failing + var environment = MultiplayerEnvironment.unimplemented environment.gameCenter.localPlayer.localPlayer = { .authenticated } - environment.gameCenter.turnBasedMatchmakerViewController.present = { _ in - .fireAndForget { - didPresentMatchmakerViewController = true - } + environment.gameCenter.turnBasedMatchmakerViewController.present = { @Sendable _ in + await didPresentMatchmakerViewController.setValue(true) } let store = TestStore( @@ -21,18 +20,17 @@ class MultiplayerFeatureTests: XCTestCase { environment: environment ) - store.send(.startButtonTapped) - - XCTAssertNoDifference(didPresentMatchmakerViewController, true) + await store.send(.startButtonTapped) + await didPresentMatchmakerViewController.withValue { XCTAssertNoDifference($0, true) } } - func testStartGame_GameCenterNotAuthenticated() { - var didPresentAuthentication = false + func testStartGame_GameCenterNotAuthenticated() async { + let didPresentAuthentication = ActorIsolated(false) - var environment = MultiplayerEnvironment.failing + var environment = MultiplayerEnvironment.unimplemented environment.gameCenter.localPlayer.localPlayer = { .notAuthenticated } - environment.gameCenter.localPlayer.presentAuthenticationViewController = .fireAndForget { - didPresentAuthentication = true + environment.gameCenter.localPlayer.presentAuthenticationViewController = { + await didPresentAuthentication.setValue(true) } let store = TestStore( @@ -41,31 +39,30 @@ class MultiplayerFeatureTests: XCTestCase { environment: environment ) - store.send(.startButtonTapped) - - XCTAssertNoDifference(didPresentAuthentication, true) + await store.send(.startButtonTapped) + await didPresentAuthentication.withValue { XCTAssertNoDifference($0, true) } } - func testNavigateToPastGames() { + func testNavigateToPastGames() async { let store = TestStore( initialState: MultiplayerState(hasPastGames: true), reducer: multiplayerReducer, - environment: .failing + environment: .unimplemented ) - store.send(.setNavigation(tag: .pastGames)) { + await store.send(.setNavigation(tag: .pastGames)) { $0.route = .pastGames(.init(pastGames: [])) } - store.send(.setNavigation(tag: nil)) { + await store.send(.setNavigation(tag: nil)) { $0.route = nil } } } extension MultiplayerEnvironment { - static let failing = Self( - backgroundQueue: .failing("backgroundQueue"), - gameCenter: .failing, - mainQueue: .failing("mainQueue") + static let unimplemented = Self( + backgroundQueue: .unimplemented("backgroundQueue"), + gameCenter: .unimplemented, + mainQueue: .unimplemented("mainQueue") ) } diff --git a/Tests/MultiplayerFeatureTests/PastGamesTests.swift b/Tests/MultiplayerFeatureTests/PastGamesTests.swift index a5391b99..c19105c7 100644 --- a/Tests/MultiplayerFeatureTests/PastGamesTests.swift +++ b/Tests/MultiplayerFeatureTests/PastGamesTests.swift @@ -8,13 +8,12 @@ import XCTest @testable import MultiplayerFeature +@MainActor class PastGamesTests: XCTestCase { - func testLoadMatches() { - var environment = PastGamesEnvironment.failing - environment.backgroundQueue = .immediate + func testLoadMatches() async { + var environment = PastGamesEnvironment.unimplemented environment.gameCenter.localPlayer.localPlayer = { .authenticated } - environment.gameCenter.turnBasedMatch.loadMatches = { .init(value: [match]) } - environment.mainQueue = .immediate + environment.gameCenter.turnBasedMatch.loadMatches = { [match] } let store = TestStore( initialState: PastGamesState(), @@ -22,17 +21,16 @@ class PastGamesTests: XCTestCase { environment: environment ) - store.send(.onAppear) + await store.send(.task) - store.receive(.matchesResponse(.success([pastGameState]))) { + await store.receive(.matchesResponse(.success([pastGameState]))) { $0.pastGames = [pastGameState] } } - func testOpenMatch() { - var environment = PastGamesEnvironment.failing - environment.gameCenter.turnBasedMatch.load = { _ in .init(value: match) } - environment.mainQueue = .immediate + func testOpenMatch() async { + var environment = PastGamesEnvironment.unimplemented + environment.gameCenter.turnBasedMatch.load = { _ in match } let store = TestStore( initialState: PastGamesState(pastGames: [pastGameState]), @@ -40,17 +38,16 @@ class PastGamesTests: XCTestCase { environment: environment ) - store.send(.pastGame("id", .tappedRow)) + await store.send(.pastGame("id", .tappedRow)) - store.receive(.pastGame("id", .matchResponse(.success(match)))) + await store.receive(.pastGame("id", .matchResponse(.success(match)))) - store.receive(.pastGame("id", .delegate(.openMatch(match)))) + await store.receive(.pastGame("id", .delegate(.openMatch(match)))) } - func testRematch() { - var environment = PastGamesEnvironment.failing - environment.mainQueue = .immediate - environment.gameCenter.turnBasedMatch.rematch = { _ in .init(value: match) } + func testRematch() async { + var environment = PastGamesEnvironment.unimplemented + environment.gameCenter.turnBasedMatch.rematch = { _ in match } let store = TestStore( initialState: PastGamesState(pastGames: [pastGameState]), @@ -58,27 +55,26 @@ class PastGamesTests: XCTestCase { environment: environment ) - store.send(.pastGame("id", .rematchButtonTapped)) { + await store.send(.pastGame("id", .rematchButtonTapped)) { try XCTUnwrap(&$0.pastGames[id: "id"]) { $0.isRematchRequestInFlight = true } } - store.receive(.pastGame("id", .rematchResponse(.success(match)))) { + await store.receive(.pastGame("id", .rematchResponse(.success(match)))) { try XCTUnwrap(&$0.pastGames[id: "id"]) { $0.isRematchRequestInFlight = false } } - store.receive(.pastGame("id", .delegate(.openMatch(match)))) + await store.receive(.pastGame("id", .delegate(.openMatch(match)))) } - func testRematch_Failure() { + func testRematch_Failure() async { struct RematchFailure: Error, Equatable {} - var environment = PastGamesEnvironment.failing - environment.mainQueue = .immediate - environment.gameCenter.turnBasedMatch.rematch = { _ in .init(error: RematchFailure()) } + var environment = PastGamesEnvironment.unimplemented + environment.gameCenter.turnBasedMatch.rematch = { _ in throw RematchFailure() } let store = TestStore( initialState: PastGamesState(pastGames: [pastGameState]), @@ -86,13 +82,13 @@ class PastGamesTests: XCTestCase { environment: environment ) - store.send(.pastGame("id", .rematchButtonTapped)) { + await store.send(.pastGame("id", .rematchButtonTapped)) { try XCTUnwrap(&$0.pastGames[id: "id"]) { $0.isRematchRequestInFlight = true } } - store.receive(.pastGame("id", .rematchResponse(.failure(RematchFailure() as NSError)))) { + await store.receive(.pastGame("id", .rematchResponse(.failure(RematchFailure())))) { try XCTUnwrap(&$0.pastGames[id: "id"]) { $0.isRematchRequestInFlight = false $0.alert = .init( @@ -106,11 +102,7 @@ class PastGamesTests: XCTestCase { } extension PastGamesEnvironment { - static let failing = Self( - backgroundQueue: .failing("backgroundQueue"), - gameCenter: .failing, - mainQueue: .failing("mainQueue") - ) + static let unimplemented = Self(gameCenter: .unimplemented) } private let pastGameState = PastGameState( diff --git a/Tests/OnboardingFeatureTests/OnboardingFeatureTests.swift b/Tests/OnboardingFeatureTests/OnboardingFeatureTests.swift index 6e7938ca..243a05f0 100644 --- a/Tests/OnboardingFeatureTests/OnboardingFeatureTests.swift +++ b/Tests/OnboardingFeatureTests/OnboardingFeatureTests.swift @@ -4,13 +4,14 @@ import XCTest @testable import OnboardingFeature +@MainActor class OnboardingFeatureTests: XCTestCase { let mainQueue = DispatchQueue.test - func testBasics_FirstLaunch() { - var isFirstLaunchOnboardingKeySet = false + func testBasics_FirstLaunch() async { + let isFirstLaunchOnboardingKeySet = ActorIsolated(false) - var environment = OnboardingEnvironment.failing + var environment = OnboardingEnvironment.unimplemented environment.audioPlayer = .noop environment.backgroundQueue = .immediate environment.dictionary.load = { _ in true } @@ -21,11 +22,9 @@ class OnboardingFeatureTests: XCTestCase { environment.mainRunLoop = .immediate environment.mainQueue = self.mainQueue.eraseToAnyScheduler() environment.userDefaults.setBool = { value, key in - .fireAndForget { - XCTAssertNoDifference(key, "hasShownFirstLaunchOnboardingKey") - XCTAssertNoDifference(value, true) - isFirstLaunchOnboardingKeySet = true - } + XCTAssertNoDifference(key, "hasShownFirstLaunchOnboardingKey") + XCTAssertNoDifference(value, true) + await isFirstLaunchOnboardingKeySet.setValue(true) } let store = TestStore( @@ -34,41 +33,41 @@ class OnboardingFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) + await store.send(.task) - self.mainQueue.advance(by: .seconds(4)) - store.receive(.delayedNextStep) { + await self.mainQueue.advance(by: .seconds(4)) + await store.receive(.delayedNextStep) { $0.step = .step2_FindWordsOnCube } - store.send(.nextButtonTapped) { + await store.send(.nextButtonTapped) { $0.step = .step3_ConnectLettersTouching } - store.send(.nextButtonTapped) { + await store.send(.nextButtonTapped) { $0.step = .step4_FindGame } // Find and submit "GAME" - store.send(.game(.tap(.began, .init(index: .init(x: .one, y: .two, z: .two), side: .left)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .one, y: .two, z: .two), side: .left)))) { $0.game.optimisticallySelectedFace = .init(index: .init(x: .one, y: .two, z: .two), side: .left) $0.game.selectedWord.append(.init(index: .init(x: .one, y: .two, z: .two), side: .left)) } - store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .two, z: .two), side: .left)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .two, z: .two), side: .left)))) { $0.game.optimisticallySelectedFace = .init(index: .init(x: .two, y: .two, z: .two), side: .left) $0.game.selectedWord.append(.init(index: .init(x: .two, y: .two, z: .two), side: .left)) } - store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .two, z: .two), side: .right)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .two, z: .two), side: .right)))) { $0.game.optimisticallySelectedFace = .init(index: .init(x: .two, y: .two, z: .two), side: .right) $0.game.selectedWord.append(.init(index: .init(x: .two, y: .two, z: .two), side: .right)) } - store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .two, z: .one), side: .right)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .two, z: .one), side: .right)))) { $0.step = .step5_SubmitGame $0.game.optimisticallySelectedFace = .init(index: .init(x: .two, y: .two, z: .one), side: .right) $0.game.selectedWord.append(.init(index: .init(x: .two, y: .two, z: .one), side: .right)) $0.game.selectedWordIsValid = true } - store.send(.game(.submitButtonTapped(reaction: nil))) { + await store.send(.game(.submitButtonTapped(reaction: nil))) { $0.game.cubes[.one][.two][.two].left.useCount += 1 $0.game.cubes[.two][.two][.two].left.useCount += 1 $0.game.cubes[.two][.two][.two].right.useCount += 1 @@ -93,38 +92,38 @@ class OnboardingFeatureTests: XCTestCase { } // Wait a moment to automatically go to the next step - self.mainQueue.advance(by: .seconds(2)) - store.receive(.delayedNextStep) { + await self.mainQueue.advance(by: .seconds(2)) + await store.receive(.delayedNextStep) { $0.step = .step7_BiggerCube } - store.send(.nextButtonTapped) { + await store.send(.nextButtonTapped) { $0.step = .step8_FindCubes } // Find and submit the word "CUBES" - store.send(.game(.tap(.began, .init(index: .init(x: .one, y: .two, z: .two), side: .top)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .one, y: .two, z: .two), side: .top)))) { $0.game.optimisticallySelectedFace = .init(index: .init(x: .one, y: .two, z: .two), side: .top) $0.game.selectedWord.append(.init(index: .init(x: .one, y: .two, z: .two), side: .top)) } - store.send(.game(.tap(.began, .init(index: .init(x: .one, y: .two, z: .one), side: .top)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .one, y: .two, z: .one), side: .top)))) { $0.game.optimisticallySelectedFace = .init(index: .init(x: .one, y: .two, z: .one), side: .top) $0.game.selectedWord.append(.init(index: .init(x: .one, y: .two, z: .one), side: .top)) } - store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .two, z: .two), side: .top)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .two, z: .two), side: .top)))) { $0.game.optimisticallySelectedFace = .init(index: .init(x: .two, y: .two, z: .two), side: .top) $0.game.selectedWord.append(.init(index: .init(x: .two, y: .two, z: .two), side: .top)) } - store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .two, z: .one), side: .right)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .two, z: .one), side: .right)))) { $0.game.optimisticallySelectedFace = .init(index: .init(x: .two, y: .two, z: .one), side: .right) $0.game.selectedWord.append(.init(index: .init(x: .two, y: .two, z: .one), side: .right)) } - store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .two, z: .one), side: .top)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .two, z: .one), side: .top)))) { $0.game.optimisticallySelectedFace = .init(index: .init(x: .two, y: .two, z: .one), side: .top) $0.game.selectedWord.append(.init(index: .init(x: .two, y: .two, z: .one), side: .top)) $0.game.selectedWordIsValid = true } - store.send(.game(.submitButtonTapped(reaction: nil))) { + await store.send(.game(.submitButtonTapped(reaction: nil))) { $0.game.cubes[.one][.two][.two].top.useCount += 1 $0.game.cubes[.one][.two][.one].top.useCount += 1 $0.game.cubes[.two][.two][.two].top.useCount += 1 @@ -151,44 +150,44 @@ class OnboardingFeatureTests: XCTestCase { } // Wait a moment to automatically go to the next step - self.mainQueue.advance(by: .seconds(2)) - store.receive(.delayedNextStep) { + await self.mainQueue.advance(by: .seconds(2)) + await store.receive(.delayedNextStep) { $0.step = .step10_CubeDisappear } - store.send(.nextButtonTapped) { + await store.send(.nextButtonTapped) { $0.step = .step11_FindRemove } // Find and submit the word "REMOVE" - store.send(.game(.tap(.began, .init(index: .init(x: .one, y: .one, z: .two), side: .left)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .one, y: .one, z: .two), side: .left)))) { $0.game.optimisticallySelectedFace = .init(index: .init(x: .one, y: .one, z: .two), side: .left) $0.game.selectedWord.append(.init(index: .init(x: .one, y: .one, z: .two), side: .left)) } - store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .one, z: .two), side: .left)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .one, z: .two), side: .left)))) { $0.game.optimisticallySelectedFace = .init(index: .init(x: .two, y: .one, z: .two), side: .left) $0.game.selectedWord.append(.init(index: .init(x: .two, y: .one, z: .two), side: .left)) } - store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .two, z: .two), side: .right)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .two, z: .two), side: .right)))) { $0.game.optimisticallySelectedFace = .init(index: .init(x: .two, y: .two, z: .two), side: .right) $0.game.selectedWord.append(.init(index: .init(x: .two, y: .two, z: .two), side: .right)) } - store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .one, z: .two), side: .right)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .one, z: .two), side: .right)))) { $0.game.optimisticallySelectedFace = .init(index: .init(x: .two, y: .one, z: .two), side: .right) $0.game.selectedWord.append(.init(index: .init(x: .two, y: .one, z: .two), side: .right)) } - store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .one, z: .one), side: .right)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .one, z: .one), side: .right)))) { $0.game.optimisticallySelectedFace = .init(index: .init(x: .two, y: .one, z: .one), side: .right) $0.game.selectedWord.append(.init(index: .init(x: .two, y: .one, z: .one), side: .right)) } - store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .two, z: .one), side: .right)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .two, z: .one), side: .right)))) { $0.game.cubeStartedShakingAt = environment.mainRunLoop.now.date $0.game.optimisticallySelectedFace = .init(index: .init(x: .two, y: .two, z: .one), side: .right) $0.game.selectedWord.append(.init(index: .init(x: .two, y: .two, z: .one), side: .right)) $0.game.selectedWordIsValid = true $0.step = .step12_CubeIsShaking } - store.send(.game(.submitButtonTapped(reaction: nil))) { + await store.send(.game(.submitButtonTapped(reaction: nil))) { $0.game.cubeStartedShakingAt = nil $0.game.cubes[.one][.one][.two].left.useCount += 1 $0.game.cubes[.two][.one][.two].left.useCount += 1 @@ -217,37 +216,37 @@ class OnboardingFeatureTests: XCTestCase { $0.step = .step13_Congrats } - self.mainQueue.advance(by: .seconds(3)) - store.receive(.delayedNextStep) { + await self.mainQueue.advance(by: .seconds(3)) + await store.receive(.delayedNextStep) { $0.step = .step14_LettersRevealed } - store.send(.nextButtonTapped) { + await store.send(.nextButtonTapped) { $0.step = .step15_FullCube } - store.send(.nextButtonTapped) { + await store.send(.nextButtonTapped) { $0.step = .step16_FindAnyWord } // Find the word "WORD" - store.send(.game(.tap(.began, .init(index: .init(x: .zero, y: .zero, z: .two), side: .left)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .zero, y: .zero, z: .two), side: .left)))) { $0.game.optimisticallySelectedFace = .init(index: .init(x: .zero, y: .zero, z: .two), side: .left) $0.game.selectedWord.append(.init(index: .init(x: .zero, y: .zero, z: .two), side: .left)) } - store.send(.game(.tap(.began, .init(index: .init(x: .one, y: .zero, z: .two), side: .left)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .one, y: .zero, z: .two), side: .left)))) { $0.game.optimisticallySelectedFace = .init(index: .init(x: .one, y: .zero, z: .two), side: .left) $0.game.selectedWord.append(.init(index: .init(x: .one, y: .zero, z: .two), side: .left)) } - store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .zero, z: .two), side: .left)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .zero, z: .two), side: .left)))) { $0.game.optimisticallySelectedFace = .init(index: .init(x: .two, y: .zero, z: .two), side: .left) $0.game.selectedWord.append(.init(index: .init(x: .two, y: .zero, z: .two), side: .left)) } - store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .zero, z: .two), side: .right)))) { + await store.send(.game(.tap(.began, .init(index: .init(x: .two, y: .zero, z: .two), side: .right)))) { $0.game.optimisticallySelectedFace = .init(index: .init(x: .two, y: .zero, z: .two), side: .right) $0.game.selectedWord.append(.init(index: .init(x: .two, y: .zero, z: .two), side: .right)) $0.game.selectedWordIsValid = true } - store.send(.game(.submitButtonTapped(reaction: nil))) { + await store.send(.game(.submitButtonTapped(reaction: nil))) { $0.game.cubes[.zero][.zero][.two].left.useCount += 1 $0.game.cubes[.one][.zero][.two].left.useCount += 1 $0.game.cubes[.two][.zero][.two].left.useCount += 1 @@ -271,17 +270,17 @@ class OnboardingFeatureTests: XCTestCase { $0.step = .step17_Congrats } - self.mainQueue.advance(by: .seconds(2)) - store.receive(.delayedNextStep) { + await self.mainQueue.advance(by: .seconds(2)) + await store.receive(.delayedNextStep) { $0.step = .step18_OneLastThing } - store.send(.nextButtonTapped) { + await store.send(.nextButtonTapped) { $0.step = .step19_DoubleTapToRemove } - store.send(.game(.doubleTap(index: .init(x: .two, y: .two, z: .two)))) - store.receive(.game(.confirmRemoveCube(.init(x: .two, y: .two, z: .two)))) { + await store.send(.game(.doubleTap(index: .init(x: .two, y: .two, z: .two)))) + await store.receive(.game(.confirmRemoveCube(.init(x: .two, y: .two, z: .two)))) { $0.game.cubes[.two][.two][.two].wasRemoved = true $0.game.moves.append( .init( @@ -295,21 +294,21 @@ class OnboardingFeatureTests: XCTestCase { $0.step = .step20_Congrats } - self.mainQueue.advance(by: .seconds(2)) - store.receive(.delayedNextStep) { + await self.mainQueue.advance(by: .seconds(2)) + await store.receive(.delayedNextStep) { $0.step = .step21_PlayAGameYourself } - store.send(.getStartedButtonTapped) - store.receive(.delegate(.getStarted)) + await store.send(.getStartedButtonTapped) + await store.receive(.delegate(.getStarted)) - XCTAssertNoDifference(isFirstLaunchOnboardingKeySet, true) + await isFirstLaunchOnboardingKeySet.withValue { XCTAssert($0) } } - func testSkip_HasSeenOnboardingBefore() { - var isFirstLaunchOnboardingKeySet = false + func testSkip_HasSeenOnboardingBefore() async { + let isFirstLaunchOnboardingKeySet = ActorIsolated(false) - var environment = OnboardingEnvironment.failing + var environment = OnboardingEnvironment.unimplemented environment.audioPlayer = .noop environment.backgroundQueue = .immediate environment.dictionary.load = { _ in true } @@ -319,11 +318,9 @@ class OnboardingFeatureTests: XCTestCase { return true } environment.userDefaults.setBool = { value, key in - .fireAndForget { - XCTAssertNoDifference(key, "hasShownFirstLaunchOnboardingKey") - XCTAssertNoDifference(value, true) - isFirstLaunchOnboardingKeySet = true - } + XCTAssertNoDifference(key, "hasShownFirstLaunchOnboardingKey") + XCTAssertNoDifference(value, true) + await isFirstLaunchOnboardingKeySet.setValue(true) } let store = TestStore( @@ -332,24 +329,24 @@ class OnboardingFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) + await store.send(.task) - self.mainQueue.advance(by: .seconds(4)) - store.receive(.delayedNextStep) { + await self.mainQueue.advance(by: .seconds(4)) + await store.receive(.delayedNextStep) { $0.step = .step2_FindWordsOnCube } - store.send(.skipButtonTapped) + await store.send(.skipButtonTapped) - store.receive(.delegate(.getStarted)) + await store.receive(.delegate(.getStarted)) - XCTAssertNoDifference(isFirstLaunchOnboardingKeySet, true) + await isFirstLaunchOnboardingKeySet.withValue { XCTAssert($0) } } - func testSkip_HasNotSeenOnboardingBefore() { - var isFirstLaunchOnboardingKeySet = false + func testSkip_HasNotSeenOnboardingBefore() async { + let isFirstLaunchOnboardingKeySet = ActorIsolated(false) - var environment = OnboardingEnvironment.failing + var environment = OnboardingEnvironment.unimplemented environment.audioPlayer = .noop environment.backgroundQueue = .immediate environment.dictionary.load = { _ in true } @@ -359,11 +356,9 @@ class OnboardingFeatureTests: XCTestCase { return false } environment.userDefaults.setBool = { value, key in - .fireAndForget { - XCTAssertNoDifference(key, "hasShownFirstLaunchOnboardingKey") - XCTAssertNoDifference(value, true) - isFirstLaunchOnboardingKeySet = true - } + XCTAssertNoDifference(key, "hasShownFirstLaunchOnboardingKey") + XCTAssertNoDifference(value, true) + await isFirstLaunchOnboardingKeySet.setValue(true) } let store = TestStore( @@ -372,14 +367,14 @@ class OnboardingFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) + await store.send(.task) - self.mainQueue.advance(by: .seconds(4)) - store.receive(.delayedNextStep) { + await self.mainQueue.advance(by: .seconds(4)) + await store.receive(.delayedNextStep) { $0.step = .step2_FindWordsOnCube } - store.send(.skipButtonTapped) { + await store.send(.skipButtonTapped) { $0.alert = .init( title: .init("Skip tutorial?"), message: .init(""" @@ -395,30 +390,27 @@ class OnboardingFeatureTests: XCTestCase { ) } - store.send(.alert(.skipButtonTapped)) { + await store.send(.alert(.skipButtonTapped)) { $0.alert = nil - } - - store.receive(.alert(.confirmSkipButtonTapped)) { $0.step = .step21_PlayAGameYourself } - store.send(.getStartedButtonTapped) - store.receive(.delegate(.getStarted)) + await store.send(.getStartedButtonTapped) + await store.receive(.delegate(.getStarted)) - XCTAssertNoDifference(isFirstLaunchOnboardingKeySet, true) + await isFirstLaunchOnboardingKeySet.withValue { XCTAssert($0) } } } extension OnboardingEnvironment { - static let failing = Self( - audioPlayer: .failing, - backgroundQueue: .failing("backgroundQueue"), - dictionary: .failing, - feedbackGenerator: .failing, - lowPowerMode: .failing, - mainQueue: .failing("mainQueue"), - mainRunLoop: .failing, - userDefaults: .failing + static let unimplemented = Self( + audioPlayer: .unimplemented, + backgroundQueue: .unimplemented("backgroundQueue"), + dictionary: .unimplemented, + feedbackGenerator: .unimplemented, + lowPowerMode: .unimplemented, + mainQueue: .unimplemented("mainQueue"), + mainRunLoop: .unimplemented, + userDefaults: .unimplemented ) } diff --git a/Tests/PushMiddlewareTests/PushMiddlewareTests.swift b/Tests/PushMiddlewareTests/PushMiddlewareTests.swift index 6da02d53..6f38d79d 100644 --- a/Tests/PushMiddlewareTests/PushMiddlewareTests.swift +++ b/Tests/PushMiddlewareTests/PushMiddlewareTests.swift @@ -23,7 +23,7 @@ class PushMiddlewareTests: XCTestCase { var createPlatformRequest: SnsClient.CreatePlatformRequest? var insertPushTokenRequest: DatabaseClient.InsertPushTokenRequest? - let environment = update(ServerEnvironment.failing) { + let environment = update(ServerEnvironment.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(.blob) } $0.database.insertPushToken = { request in insertPushTokenRequest = request @@ -87,7 +87,7 @@ class PushMiddlewareTests: XCTestCase { var createPlatformRequest: SnsClient.CreatePlatformRequest? var insertPushTokenRequest: DatabaseClient.InsertPushTokenRequest? - let environment = update(ServerEnvironment.failing) { + let environment = update(ServerEnvironment.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(.blob) } $0.database.insertPushToken = { request in insertPushTokenRequest = request @@ -151,7 +151,7 @@ class PushMiddlewareTests: XCTestCase { var createPlatformRequest: SnsClient.CreatePlatformRequest? var insertPushTokenRequest: DatabaseClient.InsertPushTokenRequest? - let environment = update(ServerEnvironment.failing) { + let environment = update(ServerEnvironment.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(.blob) } $0.database.insertPushToken = { request in insertPushTokenRequest = request @@ -215,7 +215,7 @@ class PushMiddlewareTests: XCTestCase { var createPlatformRequest: SnsClient.CreatePlatformRequest? var insertPushTokenRequest: DatabaseClient.InsertPushTokenRequest? - let environment = update(ServerEnvironment.failing) { + let environment = update(ServerEnvironment.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(.blob) } $0.database.insertPushToken = { request in insertPushTokenRequest = request @@ -282,7 +282,7 @@ class PushMiddlewareTests: XCTestCase { var notificationType: PushNotificationContent.CodingKeys? var sendNotifications: Bool? - let environment = update(ServerEnvironment.failing) { + let environment = update(ServerEnvironment.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(.blob) } $0.database.updatePushSetting = { (playerId, notificationType, sendNotifications) = ($0, $1, $2) diff --git a/Tests/RunnerTests/RunnerTests.swift b/Tests/RunnerTests/RunnerTests.swift index 1dc5dd1a..96c7c442 100644 --- a/Tests/RunnerTests/RunnerTests.swift +++ b/Tests/RunnerTests/RunnerTests.swift @@ -17,7 +17,7 @@ final class RunnerTests: XCTestCase { var targetArn: String? var payload: AnyEncodable? - let environment = update(ServerEnvironment.failing) { + let environment = update(ServerEnvironment.unimplemented) { $0.database.fetchActiveDailyChallengeArns = { pure([DatabaseClient.DailyChallengeArn(arn: "arn-deadbeef", endsAt: now + 60 * 60)]) } diff --git a/Tests/ServerConfigMiddlewareTests/ServerConfigMiddlewareTests.swift b/Tests/ServerConfigMiddlewareTests/ServerConfigMiddlewareTests.swift index b23ddf52..0baf88d8 100644 --- a/Tests/ServerConfigMiddlewareTests/ServerConfigMiddlewareTests.swift +++ b/Tests/ServerConfigMiddlewareTests/ServerConfigMiddlewareTests.swift @@ -25,8 +25,8 @@ class ServerConfigMiddlewareTests: XCTestCase { )! ) - var environment = ServerEnvironment.failing - environment.database = .failing + var environment = ServerEnvironment.unimplemented + environment.database = .unimplemented environment.changelog = { .init(changes: [.init(version: "2.0", build: 42, log: "")]) } @@ -79,8 +79,8 @@ class ServerConfigMiddlewareTests: XCTestCase { )! ) - var environment = ServerEnvironment.failing - environment.database = .failing + var environment = ServerEnvironment.unimplemented + environment.database = .unimplemented environment.changelog = { .init(changes: [.init(version: "2.0", build: 42, log: "")]) } diff --git a/Tests/SettingsFeatureTests/SettingsFeatureTests.swift b/Tests/SettingsFeatureTests/SettingsFeatureTests.swift index 0bc7156b..958ca975 100644 --- a/Tests/SettingsFeatureTests/SettingsFeatureTests.swift +++ b/Tests/SettingsFeatureTests/SettingsFeatureTests.swift @@ -1,3 +1,4 @@ +import ApiClient import Combine import ComposableArchitecture import ComposableUserNotifications @@ -8,17 +9,20 @@ import XCTest @testable import SettingsFeature +@MainActor class SettingsFeatureTests: XCTestCase { var defaultEnvironment: SettingsEnvironment { - var environment = SettingsEnvironment.failing + var environment = SettingsEnvironment.unimplemented environment.apiClient.baseUrl = { URL(string: "http://localhost:9876")! } environment.apiClient.currentPlayer = { .some(.init(appleReceipt: .mock, player: .blob)) } environment.build.number = { 42 } environment.mainQueue = .immediate environment.backgroundQueue = .immediate - environment.fileClient.save = { _, _ in .none } - environment.storeKit.fetchProducts = { _ in .none } - environment.storeKit.observer = .run { _ in AnyCancellable {} } + environment.fileClient.save = { @Sendable _, _ in } + environment.storeKit.fetchProducts = { _ in + .init(invalidProductIdentifiers: [], products: []) + } + environment.storeKit.observer = { .finished } return environment } @@ -53,24 +57,22 @@ class SettingsFeatureTests: XCTestCase { // MARK: - Notifications - func testEnableNotifications_NotDetermined_GrantAuthorization() { - var didRegisterForRemoteNotifications = false + func testEnableNotifications_NotDetermined_GrantAuthorization() async { + let didRegisterForRemoteNotifications = ActorIsolated(false) var environment = self.defaultEnvironment environment.applicationClient.alternateIconName = { nil } environment.backgroundQueue = .immediate - environment.fileClient.save = { _, _ in .none } + environment.fileClient.save = { @Sendable _, _ in } environment.mainQueue = .immediate environment.serverConfig.config = { .init() } environment.userDefaults.boolForKey = { _ in false } - environment.userNotifications.getNotificationSettings = .init( - value: .init(authorizationStatus: .notDetermined) - ) - environment.userNotifications.requestAuthorization = { _ in .init(value: true) } + environment.userNotifications.getNotificationSettings = { + .init(authorizationStatus: .notDetermined) + } + environment.userNotifications.requestAuthorization = { _ in true } environment.remoteNotifications.register = { - .fireAndForget { - didRegisterForRemoteNotifications = true - } + await didRegisterForRemoteNotifications.setValue(true) } let store = TestStore( @@ -79,39 +81,39 @@ class SettingsFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) { + let task = await store.send(.task) { $0.buildNumber = 42 $0.developer.currentBaseUrl = .localhost $0.fullGamePurchasedAt = .mock } - store.receive(.userNotificationSettingsResponse(.init(authorizationStatus: .notDetermined))) { + await store.receive(.userNotificationSettingsResponse(.init(authorizationStatus: .notDetermined))) { $0.userNotificationSettings = .init(authorizationStatus: .notDetermined) } - store.send(.set(\.$enableNotifications, true)) { + await store.send(.set(\.$enableNotifications, true)) { $0.enableNotifications = true } - store.receive(.userNotificationAuthorizationResponse(.success(true))) + await store.receive(.userNotificationAuthorizationResponse(.success(true))) - XCTAssert(didRegisterForRemoteNotifications) + await didRegisterForRemoteNotifications.withValue { XCTAssert($0) } - store.send(.onDismiss) + await task.cancel() } - func testEnableNotifications_NotDetermined_DenyAuthorization() { + func testEnableNotifications_NotDetermined_DenyAuthorization() async { var environment = self.defaultEnvironment environment.applicationClient.alternateIconName = { nil } environment.backgroundQueue = .immediate - environment.fileClient.save = { _, _ in .none } + environment.fileClient.save = { @Sendable _, _ in } environment.mainQueue = .immediate environment.serverConfig.config = { .init() } environment.userDefaults.boolForKey = { _ in false } - environment.userNotifications.getNotificationSettings = .init( - value: .init(authorizationStatus: .notDetermined) - ) - environment.userNotifications.requestAuthorization = { _ in .init(value: false) } + environment.userNotifications.getNotificationSettings = { + .init(authorizationStatus: .notDetermined) + } + environment.userNotifications.requestAuthorization = { _ in false } let store = TestStore( initialState: SettingsState(), @@ -119,39 +121,38 @@ class SettingsFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) { + let task = await store.send(.task) { $0.buildNumber = 42 $0.developer.currentBaseUrl = .localhost $0.fullGamePurchasedAt = .mock } - store.receive(.userNotificationSettingsResponse(.init(authorizationStatus: .notDetermined))) { + await store.receive(.userNotificationSettingsResponse(.init(authorizationStatus: .notDetermined))) { $0.userNotificationSettings = .init(authorizationStatus: .notDetermined) } - store.send(.set(\.$enableNotifications, true)) { + await store.send(.set(\.$enableNotifications, true)) { $0.enableNotifications = true } - store.receive(.userNotificationAuthorizationResponse(.success(false))) { + await store.receive(.userNotificationAuthorizationResponse(.success(false))) { $0.enableNotifications = false } - store.send(.onDismiss) + await task.cancel() } - func testNotifications_PreviouslyGranted() { + func testNotifications_PreviouslyGranted() async { var environment = self.defaultEnvironment environment.applicationClient.alternateIconName = { nil } environment.backgroundQueue = .immediate - environment.fileClient.save = { _, _ in .none } + environment.fileClient.save = { @Sendable _, _ in } environment.mainQueue = .immediate - environment.remoteNotifications.register = { .none } environment.serverConfig.config = { .init() } environment.userDefaults.boolForKey = { _ in false } - environment.userNotifications.getNotificationSettings = .init( - value: .init(authorizationStatus: .authorized) - ) + environment.userNotifications.getNotificationSettings = { + .init(authorizationStatus: .authorized) + } let store = TestStore( initialState: SettingsState(), @@ -159,42 +160,44 @@ class SettingsFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) { + let task = await store.send(.task) { $0.buildNumber = 42 $0.developer.currentBaseUrl = .localhost $0.fullGamePurchasedAt = .mock } - store.receive(.userNotificationSettingsResponse(.init(authorizationStatus: .authorized))) { + await store.receive(.userNotificationSettingsResponse(.init(authorizationStatus: .authorized))) { $0.enableNotifications = true $0.userNotificationSettings = .init(authorizationStatus: .authorized) } - store.send(.set(\.$enableNotifications, false)) { + await store.send(.set(\.$enableNotifications, false)) { $0.enableNotifications = false } - store.send(.onDismiss) + await task.cancel() } - func testNotifications_PreviouslyDenied() { - var openedUrl: URL! + func testNotifications_PreviouslyDenied() async { + let openedUrl = ActorIsolated(nil) var environment = self.defaultEnvironment environment.applicationClient.alternateIconName = { nil } - environment.applicationClient.openSettingsURLString = { "settings:isowords//isowords/settings" } + environment.applicationClient.openSettingsURLString = { + "settings:isowords//isowords/settings" + } environment.applicationClient.open = { url, _ in - openedUrl = url - return .init(value: true) + await openedUrl.setValue(url) + return true } environment.backgroundQueue = .immediate - environment.fileClient.save = { _, _ in .none } + environment.fileClient.save = { @Sendable _, _ in } environment.mainQueue = .immediate environment.serverConfig.config = { .init() } environment.userDefaults.boolForKey = { _ in false } - environment.userNotifications.getNotificationSettings = .init( - value: .init(authorizationStatus: .denied) - ) + environment.userNotifications.getNotificationSettings = { + .init(authorizationStatus: .denied) + } let store = TestStore( initialState: SettingsState(), @@ -202,58 +205,53 @@ class SettingsFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) { + let task = await store.send(.task) { $0.buildNumber = 42 $0.developer.currentBaseUrl = .localhost $0.fullGamePurchasedAt = .mock } - store.receive(.userNotificationSettingsResponse(.init(authorizationStatus: .denied))) { + await store.receive(.userNotificationSettingsResponse(.init(authorizationStatus: .denied))) { $0.userNotificationSettings = .init(authorizationStatus: .denied) } - store.send(.set(\.$enableNotifications, true)) { + await store.send(.set(\.$enableNotifications, true)) { $0.alert = .userNotificationAuthorizationDenied } - store.send(.openSettingButtonTapped) + await store.send(.openSettingButtonTapped) - XCTAssertNoDifference(openedUrl, URL(string: "settings:isowords//isowords/settings")!) + await openedUrl.withValue { + XCTAssertNoDifference($0, URL(string: "settings:isowords//isowords/settings")!) + } - store.send(.set(\.$alert, nil)) { + await store.send(.set(\.$alert, nil)) { $0.alert = nil } - store.send(.onDismiss) + await task.cancel() } - func testNotifications_DebounceRemoteSettingsUpdates() { + func testNotifications_DebounceRemoteSettingsUpdates() async { let mainQueue = DispatchQueue.test var environment = self.defaultEnvironment - environment.apiClient.refreshCurrentPlayer = { .init(value: .blobWithPurchase) } - environment.apiClient.override( - route: .push( - .updateSetting(.init(notificationType: .dailyChallengeEndsSoon, sendNotifications: true)) - ), - withResponse: .none - ) + environment.apiClient.refreshCurrentPlayer = { .blobWithPurchase } environment.apiClient.override( route: .push( .updateSetting(.init(notificationType: .dailyChallengeReport, sendNotifications: true)) ), - withResponse: .none + withResponse: { try await OK([:]) } ) environment.applicationClient.alternateIconName = { nil } environment.backgroundQueue = .immediate - environment.fileClient.save = { _, _ in .none } + environment.fileClient.save = { @Sendable _, _ in } environment.mainQueue = mainQueue.eraseToAnyScheduler() - environment.remoteNotifications.register = { .none } environment.serverConfig.config = { .init() } environment.userDefaults.boolForKey = { _ in false } - environment.userNotifications.getNotificationSettings = .init( - value: .init(authorizationStatus: .authorized) - ) + environment.userNotifications.getNotificationSettings = { + .init(authorizationStatus: .authorized) + } let store = TestStore( initialState: SettingsState(sendDailyChallengeReminder: false), @@ -261,44 +259,40 @@ class SettingsFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) { + let task = await store.send(.task) { $0.buildNumber = 42 $0.developer.currentBaseUrl = .localhost $0.fullGamePurchasedAt = .mock } - mainQueue.advance() + await mainQueue.advance() - store.receive(.userNotificationSettingsResponse(.init(authorizationStatus: .authorized))) { + await store.receive(.userNotificationSettingsResponse(.init(authorizationStatus: .authorized))) { $0.enableNotifications = true $0.userNotificationSettings = .init(authorizationStatus: .authorized) } - store.send(.set(\.$sendDailyChallengeReminder, true)) { + await store.send(.set(\.$sendDailyChallengeReminder, true)) { $0.sendDailyChallengeReminder = true } - mainQueue.advance(by: 0.5) + await mainQueue.advance(by: 0.5) - store.send(.set(\.$sendDailyChallengeSummary, true)) - mainQueue.advance(by: 0.5) - mainQueue.advance(by: 0.5) + await store.send(.set(\.$sendDailyChallengeSummary, true)) + await mainQueue.advance(by: 0.5) + await mainQueue.advance(by: 0.5) - store.receive(.currentPlayerRefreshed(.success(.blobWithPurchase))) + await store.receive(.currentPlayerRefreshed(.success(.blobWithPurchase))) - store.send(.onDismiss) + await task.cancel() } // MARK: - Sounds - func testSetMusicVolume() { - var setMusicVolume: Float! + func testSetMusicVolume() async { + let setMusicVolume = ActorIsolated(nil) var environment = self.defaultEnvironment - environment.audioPlayer.setGlobalVolumeForMusic = { newValue in - .fireAndForget { - setMusicVolume = newValue - } - } + environment.audioPlayer.setGlobalVolumeForMusic = { await setMusicVolume.setValue($0) } let store = TestStore( initialState: SettingsState(), @@ -306,21 +300,19 @@ class SettingsFeatureTests: XCTestCase { environment: environment ) - store.send(.set(\.$userSettings.musicVolume, 0.5)) { + await store.send(.set(\.$userSettings.musicVolume, 0.5)) { $0.userSettings.musicVolume = 0.5 } - XCTAssertNoDifference(setMusicVolume, 0.5) + await setMusicVolume.withValue { XCTAssertNoDifference($0, 0.5) } } - func testSetSoundEffectsVolume() { - var setSoundEffectsVolume: Float! + func testSetSoundEffectsVolume() async { + let setSoundEffectsVolume = ActorIsolated(nil) var environment = self.defaultEnvironment - environment.audioPlayer.setGlobalVolumeForSoundEffects = { newValue in - .fireAndForget { - setSoundEffectsVolume = newValue - } + environment.audioPlayer.setGlobalVolumeForSoundEffects = { + await setSoundEffectsVolume.setValue($0) } let store = TestStore( @@ -329,24 +321,20 @@ class SettingsFeatureTests: XCTestCase { environment: environment ) - store.send(.set(\.$userSettings.soundEffectsVolume, 0.5)) { + await store.send(.set(\.$userSettings.soundEffectsVolume, 0.5)) { $0.userSettings.soundEffectsVolume = 0.5 } - XCTAssertNoDifference(setSoundEffectsVolume, 0.5) + await setSoundEffectsVolume.withValue { XCTAssertNoDifference($0, 0.5) } } // MARK: - Appearance - func testSetColorScheme() { - var overriddenUserInterfaceStyle: UIUserInterfaceStyle! + func testSetColorScheme() async { + let overriddenUserInterfaceStyle = ActorIsolated(nil) var environment = self.defaultEnvironment - environment.setUserInterfaceStyle = { newValue in - .fireAndForget { - overriddenUserInterfaceStyle = newValue - } - } + environment.setUserInterfaceStyle = { await overriddenUserInterfaceStyle.setValue($0) } let store = TestStore( initialState: SettingsState(), @@ -354,27 +342,22 @@ class SettingsFeatureTests: XCTestCase { environment: environment ) - store.send(.set(\.$userSettings.colorScheme, .light)) { + await store.send(.set(\.$userSettings.colorScheme, .light)) { $0.userSettings.colorScheme = .light } - XCTAssertNoDifference(overriddenUserInterfaceStyle, .light) + await overriddenUserInterfaceStyle.withValue { XCTAssertNoDifference($0, .light) } - store.send(.set(\.$userSettings.colorScheme, .system)) { + await store.send(.set(\.$userSettings.colorScheme, .system)) { $0.userSettings.colorScheme = .system } - XCTAssertNoDifference(overriddenUserInterfaceStyle, .unspecified) + await overriddenUserInterfaceStyle.withValue { XCTAssertNoDifference($0, .unspecified) } } - func testSetAppIcon() { - var overriddenIconName: String! + func testSetAppIcon() async { + let overriddenIconName = ActorIsolated(nil) var environment = self.defaultEnvironment - environment.applicationClient.setAlternateIconName = { newValue in - .fireAndForget { - overriddenIconName = newValue - } - } - environment.fileClient.save = { _, _ in .none } + environment.applicationClient.setAlternateIconName = { await overriddenIconName.setValue($0) } let store = TestStore( initialState: SettingsState(), @@ -382,28 +365,25 @@ class SettingsFeatureTests: XCTestCase { environment: environment ) - store.send(.set(\.$userSettings.appIcon, .icon2)) { + await store.send(.set(\.$userSettings.appIcon, .icon2)) { $0.userSettings.appIcon = .icon2 } - XCTAssertNoDifference(overriddenIconName, "icon-2") + await overriddenIconName.withValue { XCTAssertNoDifference($0, "icon-2") } } - func testUnsetAppIcon() { - var overriddenIconName: String? + func testUnsetAppIcon() async { + let overriddenIconName = ActorIsolated(nil) var environment = self.defaultEnvironment environment.applicationClient.alternateIconName = { "icon-2" } - environment.applicationClient.setAlternateIconName = { newValue in - .fireAndForget { - overriddenIconName = newValue - } - } + environment.applicationClient.setAlternateIconName = { await overriddenIconName.setValue($0) } environment.backgroundQueue = .immediate - environment.fileClient.save = { _, _ in .none } environment.mainQueue = .immediate environment.serverConfig.config = { .init() } environment.userDefaults.boolForKey = { _ in false } - environment.userNotifications.getNotificationSettings = .none + environment.userNotifications.getNotificationSettings = { + (try? await Task.never()) ?? .init(authorizationStatus: .notDetermined) + } let store = TestStore( initialState: SettingsState(), @@ -411,34 +391,30 @@ class SettingsFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) { + let task = await store.send(.task) { $0.buildNumber = 42 $0.developer.currentBaseUrl = .localhost $0.fullGamePurchasedAt = .mock $0.userSettings.appIcon = .icon2 } - store.send(.set(\.$userSettings.appIcon, nil)) { + await store.send(.set(\.$userSettings.appIcon, nil)) { $0.userSettings.appIcon = nil } - XCTAssertNoDifference(overriddenIconName, nil) + await overriddenIconName.withValue { XCTAssertNil($0) } - store.send(.onDismiss) + await task.cancel() } // MARK: - Developer - func testSetApiBaseUrl() { - var setBaseUrl: URL! - var didLogout = false + func testSetApiBaseUrl() async { + let setBaseUrl = ActorIsolated(nil) + let didLogout = ActorIsolated(false) - var environment = SettingsEnvironment.failing - environment.apiClient.logout = { .fireAndForget { didLogout = true } } - environment.apiClient.setBaseUrl = { newValue in - .fireAndForget { - setBaseUrl = newValue - } - } + var environment = SettingsEnvironment.unimplemented + environment.apiClient.logout = { await didLogout.setValue(true) } + environment.apiClient.setBaseUrl = { await setBaseUrl.setValue($0) } let store = TestStore( initialState: SettingsState(), @@ -446,84 +422,84 @@ class SettingsFeatureTests: XCTestCase { environment: environment ) - store.send(.set(\.$developer.currentBaseUrl, .localhost)) { + await store.send(.set(\.$developer.currentBaseUrl, .localhost)) { $0.developer.currentBaseUrl = .localhost } - XCTAssertNoDifference(setBaseUrl, URL(string: "http://localhost:9876")!) - XCTAssertNoDifference(didLogout, true) + await setBaseUrl.withValue { XCTAssertNoDifference($0, URL(string: "http://localhost:9876")!) } + await didLogout.withValue { XCTAssert($0) } } - func testToggleEnableCubeShadow() { + func testToggleEnableCubeShadow() async { let store = TestStore( initialState: SettingsState(enableCubeShadow: true), reducer: settingsReducer, - environment: .failing + environment: .unimplemented ) - store.send(.set(\.$enableCubeShadow, false)) { + await store.send(.set(\.$enableCubeShadow, false)) { $0.enableCubeShadow = false } - store.send(.set(\.$enableCubeShadow, true)) { + await store.send(.set(\.$enableCubeShadow, true)) { $0.enableCubeShadow = true } } - func testSetShadowRadius() { + func testSetShadowRadius() async { let store = TestStore( initialState: SettingsState(cubeShadowRadius: 5), reducer: settingsReducer, - environment: .failing + environment: .unimplemented ) - store.send(.set(\.$cubeShadowRadius, 20)) { + await store.send(.set(\.$cubeShadowRadius, 20)) { $0.cubeShadowRadius = 20 } - store.send(.set(\.$cubeShadowRadius, 1.5)) { + await store.send(.set(\.$cubeShadowRadius, 1.5)) { $0.cubeShadowRadius = 1.5 } } - func testToggleShowSceneStatistics() { + func testToggleShowSceneStatistics() async { let store = TestStore( initialState: SettingsState(showSceneStatistics: false), reducer: settingsReducer, - environment: .failing + environment: .unimplemented ) - store.send(.set(\.$showSceneStatistics, true)) { + await store.send(.set(\.$showSceneStatistics, true)) { $0.showSceneStatistics = true } - store.send(.set(\.$showSceneStatistics, false)) { + await store.send(.set(\.$showSceneStatistics, false)) { $0.showSceneStatistics = false } } - func testToggleEnableGyroMotion() { + func testToggleEnableGyroMotion() async { let store = TestStore( initialState: SettingsState(userSettings: .init(enableGyroMotion: true)), reducer: settingsReducer, environment: self.defaultEnvironment ) - store.send(.set(\.$userSettings.enableGyroMotion, false)) { + await store.send(.set(\.$userSettings.enableGyroMotion, false)) { $0.userSettings.enableGyroMotion = false } - store.send(.set(\.$userSettings.enableGyroMotion, true)) { + await store.send(.set(\.$userSettings.enableGyroMotion, true)) { $0.userSettings.enableGyroMotion = true } } - func testToggleEnableHaptics() { + func testToggleEnableHaptics() async { let store = TestStore( initialState: SettingsState(userSettings: .init(enableHaptics: true)), reducer: settingsReducer, environment: self.defaultEnvironment ) - store.send(.set(\.$userSettings.enableHaptics, false)) { + await store.send(.set(\.$userSettings.enableHaptics, false)) { $0.userSettings.enableHaptics = false } - store.send(.set(\.$userSettings.enableHaptics, true)) { + await store.send(.set(\.$userSettings.enableHaptics, true)) { $0.userSettings.enableHaptics = true } } diff --git a/Tests/SettingsFeatureTests/SettingsPurchaseTests.swift b/Tests/SettingsFeatureTests/SettingsPurchaseTests.swift index 9813174e..9f8ed495 100644 --- a/Tests/SettingsFeatureTests/SettingsPurchaseTests.swift +++ b/Tests/SettingsFeatureTests/SettingsPurchaseTests.swift @@ -7,41 +7,40 @@ import XCTest @testable import ServerConfig @testable import SettingsFeature +@MainActor class SettingsPurchaseTests: XCTestCase { var defaultEnvironment: SettingsEnvironment { - var environment = SettingsEnvironment.failing + var environment = SettingsEnvironment.unimplemented environment.apiClient.baseUrl = { URL(string: "http://localhost:9876")! } environment.applicationClient.alternateIconName = { nil } environment.build.number = { 42 } environment.mainQueue = .immediate environment.backgroundQueue = .immediate - environment.fileClient.save = { _, _ in .none } - environment.userNotifications.getNotificationSettings = .none - environment.userNotifications.requestAuthorization = { _ in .init(value: false) } + environment.fileClient.save = { @Sendable _, _ in } + environment.userNotifications.getNotificationSettings = { + (try? await Task.never()) ?? .init(authorizationStatus: .notDetermined) + } return environment } - func testUpgrade_HappyPath() throws { - var didAddPaymentProductIdentifier: String? = nil - let storeKitObserver = PassthroughSubject< - StoreKitClient.PaymentTransactionObserverEvent, Never - >() + func testUpgrade_HappyPath() async throws { + let didAddPaymentProductIdentifier = ActorIsolated(nil) + let storeKitObserver = AsyncStream + .streamWithContinuation() var environment = self.defaultEnvironment environment.serverConfig.config = { .init(productIdentifiers: .init(fullGame: "xyz.isowords.full_game")) } environment.apiClient.currentPlayer = { .some(.blobWithoutPurchase) } - environment.apiClient.refreshCurrentPlayer = { .init(value: .blobWithPurchase) } - environment.storeKit.addPayment = { payment in - .fireAndForget { - didAddPaymentProductIdentifier = payment.productIdentifier - } + environment.apiClient.refreshCurrentPlayer = { .blobWithPurchase } + environment.storeKit.addPayment = { + await didAddPaymentProductIdentifier.setValue($0.productIdentifier) } environment.storeKit.fetchProducts = { _ in - .init(value: .init(invalidProductIdentifiers: [], products: [.fullGame])) + .init(invalidProductIdentifiers: [], products: [.fullGame]) } - environment.storeKit.observer = storeKitObserver.eraseToEffect() + environment.storeKit.observer = { storeKitObserver.stream } let store = TestStore( initialState: SettingsState(), @@ -49,55 +48,54 @@ class SettingsPurchaseTests: XCTestCase { environment: environment ) - store.send(.onAppear) { + let task = await store.send(.task) { $0.buildNumber = 42 $0.developer.currentBaseUrl = .localhost } - store.receive( + await store.receive( .productsResponse(.success(.init(invalidProductIdentifiers: [], products: [.fullGame]))) ) { $0.fullGameProduct = .success(.fullGame) } - store.send(.tappedProduct(.fullGame)) { + await store.send(.tappedProduct(.fullGame)) { $0.isPurchasing = true } - XCTAssertNoDifference(didAddPaymentProductIdentifier, "xyz.isowords.full_game") - storeKitObserver.send(.updatedTransactions([.purchasing])) - storeKitObserver.send(.updatedTransactions([.purchased])) - storeKitObserver.send(.removedTransactions([.purchased])) - - store.receive(SettingsAction.paymentTransaction(.updatedTransactions([.purchasing]))) - store.receive(SettingsAction.paymentTransaction(.updatedTransactions([.purchased]))) - store.receive(SettingsAction.paymentTransaction(.removedTransactions([.purchased]))) { + await didAddPaymentProductIdentifier.withValue { + XCTAssertNoDifference($0, "xyz.isowords.full_game") + } + storeKitObserver.continuation.yield(.updatedTransactions([.purchasing])) + storeKitObserver.continuation.yield(.updatedTransactions([.purchased])) + storeKitObserver.continuation.yield(.removedTransactions([.purchased])) + + await store.receive(.paymentTransaction(.updatedTransactions([.purchasing]))) + await store.receive(.paymentTransaction(.updatedTransactions([.purchased]))) + await store.receive(.paymentTransaction(.removedTransactions([.purchased]))) { $0.isPurchasing = false } - store.receive(SettingsAction.currentPlayerRefreshed(.success(.blobWithPurchase))) { + await store.receive(.currentPlayerRefreshed(.success(.blobWithPurchase))) { $0.fullGamePurchasedAt = .mock } - store.send(.onDismiss) + await task.cancel() } - func testRestore_HappyPath() throws { - var didRestoreCompletedTransactions = false - let storeKitObserver = PassthroughSubject< - StoreKitClient.PaymentTransactionObserverEvent, Never - >() + func testRestore_HappyPath() async throws { + let didRestoreCompletedTransactions = ActorIsolated(false) + let storeKitObserver = AsyncStream + .streamWithContinuation() var environment = self.defaultEnvironment environment.serverConfig.config = { .init(productIdentifiers: .init(fullGame: "xyz.isowords.full_game")) } environment.apiClient.currentPlayer = { .some(.blobWithoutPurchase) } - environment.apiClient.refreshCurrentPlayer = { .init(value: .blobWithPurchase) } + environment.apiClient.refreshCurrentPlayer = { .blobWithPurchase } environment.storeKit.restoreCompletedTransactions = { - .fireAndForget { - didRestoreCompletedTransactions = true - } + await didRestoreCompletedTransactions.setValue(true) } environment.storeKit.fetchProducts = { _ in - .init(value: .init(invalidProductIdentifiers: [], products: [.fullGame])) + .init(invalidProductIdentifiers: [], products: [.fullGame]) } - environment.storeKit.observer = storeKitObserver.eraseToEffect() + environment.storeKit.observer = { storeKitObserver.stream } let store = TestStore( initialState: SettingsState(), @@ -105,55 +103,54 @@ class SettingsPurchaseTests: XCTestCase { environment: environment ) - store.send(.onAppear) { + let task = await store.send(.task) { $0.buildNumber = 42 $0.developer.currentBaseUrl = .localhost } - store.receive( + await store.receive( .productsResponse(.success(.init(invalidProductIdentifiers: [], products: [.fullGame]))) ) { $0.fullGameProduct = .success(.fullGame) } - store.send(.restoreButtonTapped) { + await store.send(.restoreButtonTapped) { $0.isRestoring = true } - XCTAssertNoDifference(didRestoreCompletedTransactions, true) - storeKitObserver.send(.updatedTransactions([.restored])) - storeKitObserver.send(.removedTransactions([.restored])) - storeKitObserver.send(.restoreCompletedTransactionsFinished(transactions: [.restored])) + await didRestoreCompletedTransactions.withValue { XCTAssertNoDifference($0, true) } + storeKitObserver.continuation.yield(.updatedTransactions([.restored])) + storeKitObserver.continuation.yield(.removedTransactions([.restored])) + storeKitObserver.continuation.yield( + .restoreCompletedTransactionsFinished(transactions: [.restored])) - store.receive(SettingsAction.paymentTransaction(.updatedTransactions([.restored]))) - store.receive(SettingsAction.paymentTransaction(.removedTransactions([.restored]))) - store.receive(SettingsAction.currentPlayerRefreshed(.success(.blobWithPurchase))) { + await store.receive(.paymentTransaction(.updatedTransactions([.restored]))) + await store.receive(.paymentTransaction(.removedTransactions([.restored]))) + await store.receive(.currentPlayerRefreshed(.success(.blobWithPurchase))) { $0.isRestoring = false $0.fullGamePurchasedAt = .mock } - store.receive(SettingsAction.paymentTransaction(.restoreCompletedTransactionsFinished(transactions: [.restored]))) - store.send(.onDismiss) + await store.receive( + .paymentTransaction(.restoreCompletedTransactionsFinished(transactions: [.restored])) + ) + await task.cancel() } - func testRestore_NoPurchasesPath() throws { - var didRestoreCompletedTransactions = false - let storeKitObserver = PassthroughSubject< - StoreKitClient.PaymentTransactionObserverEvent, Never - >() + func testRestore_NoPurchasesPath() async throws { + let didRestoreCompletedTransactions = ActorIsolated(false) + let storeKitObserver = AsyncStream + .streamWithContinuation() var environment = self.defaultEnvironment environment.serverConfig.config = { .init(productIdentifiers: .init(fullGame: "xyz.isowords.full_game")) } environment.apiClient.currentPlayer = { .some(.blobWithoutPurchase) } - environment.apiClient.refreshCurrentPlayer = { .init(value: .blobWithoutPurchase) } environment.storeKit.restoreCompletedTransactions = { - .fireAndForget { - didRestoreCompletedTransactions = true - } + await didRestoreCompletedTransactions.setValue(true) } environment.storeKit.fetchProducts = { _ in - .init(value: .init(invalidProductIdentifiers: [], products: [.fullGame])) + .init(invalidProductIdentifiers: [], products: [.fullGame]) } - environment.storeKit.observer = storeKitObserver.eraseToEffect() + environment.storeKit.observer = { storeKitObserver.stream } let store = TestStore( initialState: SettingsState(), @@ -161,51 +158,49 @@ class SettingsPurchaseTests: XCTestCase { environment: environment ) - store.send(.onAppear) { + let task = await store.send(.task) { $0.buildNumber = 42 $0.developer.currentBaseUrl = .localhost } - store.receive( + await store.receive( .productsResponse(.success(.init(invalidProductIdentifiers: [], products: [.fullGame]))) ) { $0.fullGameProduct = .success(.fullGame) } - store.send(.restoreButtonTapped) { + await store.send(.restoreButtonTapped) { $0.isRestoring = true } - XCTAssertNoDifference(didRestoreCompletedTransactions, true) - storeKitObserver.send(.restoreCompletedTransactionsFinished(transactions: [])) + await didRestoreCompletedTransactions.withValue { XCTAssertNoDifference($0, true) } + storeKitObserver.continuation.yield(.restoreCompletedTransactionsFinished(transactions: [])) - store.receive(SettingsAction.paymentTransaction(.restoreCompletedTransactionsFinished(transactions: []))) { + await store.receive( + .paymentTransaction(.restoreCompletedTransactionsFinished(transactions: [])) + ) { $0.isRestoring = false $0.alert = .noRestoredPurchases } - store.send(.onDismiss) + await task.cancel() } - func testRestore_ErrorPath() throws { - var didRestoreCompletedTransactions = false - let storeKitObserver = PassthroughSubject< - StoreKitClient.PaymentTransactionObserverEvent, Never - >() + func testRestore_ErrorPath() async throws { + let didRestoreCompletedTransactions = ActorIsolated(false) + let storeKitObserver = AsyncStream + .streamWithContinuation() var environment = self.defaultEnvironment environment.serverConfig.config = { .init(productIdentifiers: .init(fullGame: "xyz.isowords.full_game")) } environment.apiClient.currentPlayer = { .some(.blobWithoutPurchase) } - environment.apiClient.refreshCurrentPlayer = { .init(value: .blobWithoutPurchase) } environment.storeKit.restoreCompletedTransactions = { - .fireAndForget { - didRestoreCompletedTransactions = true - } + await didRestoreCompletedTransactions.setValue(true) } environment.storeKit.fetchProducts = { _ in - .init(value: .init(invalidProductIdentifiers: [], products: [.fullGame])) + .init(invalidProductIdentifiers: [], products: [.fullGame]) } - environment.storeKit.observer = storeKitObserver.eraseToEffect() + environment.storeKit.observer = { storeKitObserver.stream } let store = TestStore( initialState: SettingsState(), @@ -213,30 +208,33 @@ class SettingsPurchaseTests: XCTestCase { environment: environment ) - store.send(.onAppear) { + let task = await store.send(.task) { $0.buildNumber = 42 $0.developer.currentBaseUrl = .localhost } - store.receive( + await store.receive( .productsResponse(.success(.init(invalidProductIdentifiers: [], products: [.fullGame]))) ) { $0.fullGameProduct = .success(.fullGame) } - store.send(.restoreButtonTapped) { + await store.send(.restoreButtonTapped) { $0.isRestoring = true } - XCTAssertNoDifference(didRestoreCompletedTransactions, true) + await didRestoreCompletedTransactions.withValue { XCTAssert($0) } let restoreCompletedTransactionsError = NSError(domain: "", code: 1) - storeKitObserver.send(.restoreCompletedTransactionsFailed(restoreCompletedTransactionsError)) + storeKitObserver.continuation + .yield(.restoreCompletedTransactionsFailed(restoreCompletedTransactionsError)) - store.receive(SettingsAction.paymentTransaction(.restoreCompletedTransactionsFailed(restoreCompletedTransactionsError))) { + await store.receive( + .paymentTransaction(.restoreCompletedTransactionsFailed(restoreCompletedTransactionsError)) + ) { $0.isRestoring = false $0.alert = .restoredPurchasesFailed } - store.send(.onDismiss) + await task.cancel() } } diff --git a/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testAccessibility.1.png b/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testAccessibility.1.png index 0b49b21a..ecbd953f 100644 --- a/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testAccessibility.1.png +++ b/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testAccessibility.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ad90f62c50bf67a1ab2e0077980d7b0697bba64fa44703343b2fc9c171029131 -size 122846 +oid sha256:41160949fbae70f58d306ad43e0aa34e43051589269339c07cf4f9605a57fba0 +size 121964 diff --git a/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testAppearance.1.png b/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testAppearance.1.png index 15d3a2e4..e466a0bc 100644 --- a/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testAppearance.1.png +++ b/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testAppearance.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f90149f02c9bbf5f60386ba83de1385719f26b46bda16ac181f2ac7b4bf52b04 -size 132623 +oid sha256:29b7dd2eb8c020f85e83ea32692b93a8362d0d623cd20b4c6381d7b4148df7be +size 132626 diff --git a/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testBasics.1.png b/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testBasics.1.png index a39b4f89..722cb232 100644 --- a/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testBasics.1.png +++ b/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testBasics.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d15ee04bcd75add4e9204d5d6ef91815e2ac8cff2187faecf3787bf81b8fcc1e -size 160520 +oid sha256:e39bb36737a72fee837befecf55b0b91a1050aa7c76d4d012343701c5b6b7d73 +size 160509 diff --git a/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testNotifications.1.png b/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testNotifications.1.png index 5f631b7d..218182df 100644 --- a/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testNotifications.1.png +++ b/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testNotifications.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f58664ba3b2837fbbd0ad238dddf643128d6a1d9b9e75379bdaf13c05222194c -size 85004 +oid sha256:388772133f4e12abaab2f79b43d88389f2efbb1cf4c5d75520986c1a7290b973 +size 84759 diff --git a/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testNotifications.2.png b/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testNotifications.2.png index d3763fc8..5f52f4d3 100644 --- a/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testNotifications.2.png +++ b/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testNotifications.2.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a2c70a5b67ea1b1c8da5b35e0d12abe877af154a9c3d6efac9525be81a97f7b6 -size 141588 +oid sha256:60d95bd089c6bc9677fd2d7a52e72407b7a6bd297135b4b824b71d33f60062d7 +size 140644 diff --git a/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testSound.1.png b/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testSound.1.png index d8aaf2ce..60dc0d7f 100644 --- a/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testSound.1.png +++ b/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testSound.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:83d84561c15a36ec2e6f586b79110bc9baf9bd18c8d2b2d8fb9ef1b8365afc07 -size 103777 +oid sha256:2a0e5ed464653c04b053a2e772e1e448e657c627198a551c1cf07c20b20eb03b +size 103388 diff --git a/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testSound.2.png b/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testSound.2.png index 3d9fa654..590e6983 100644 --- a/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testSound.2.png +++ b/Tests/SettingsFeatureTests/__Snapshots__/SettingsViewTests/testSound.2.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:99e9a62091b06e095704682d38a4a677212ed921b8c5fa3aa0d9509c8529d288 -size 113414 +oid sha256:00cd8d53e5d3db086a757ba1d98fe13e4d3c0d38ad497c4a73f565838f3c8e0d +size 113384 diff --git a/Tests/ShareGameMiddlewareTests/ShareGameMiddleware.swift b/Tests/ShareGameMiddlewareTests/ShareGameMiddleware.swift index 9317b7a7..28ccf7b3 100644 --- a/Tests/ShareGameMiddlewareTests/ShareGameMiddleware.swift +++ b/Tests/ShareGameMiddlewareTests/ShareGameMiddleware.swift @@ -57,7 +57,7 @@ class ShareGameMiddlewareTests: XCTestCase { puzzle: .mock ) - let environment = update(ServerEnvironment.failing) { + let environment = update(ServerEnvironment.unimplemented) { $0.database.fetchSharedGame = { _ in pure(sharedGame) } } @@ -114,7 +114,7 @@ class ShareGameMiddlewareTests: XCTestCase { puzzle: .mock ) - let environment = update(ServerEnvironment.failing) { + let environment = update(ServerEnvironment.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(.blob) } $0.database.fetchSharedGame = { _ in pure(sharedGame) } } @@ -190,7 +190,7 @@ class ShareGameMiddlewareTests: XCTestCase { puzzle: .mock ) - let environment = update(ServerEnvironment.failing) { + let environment = update(ServerEnvironment.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(.blob) } $0.database.insertSharedGame = { completedGame, player in pure(sharedGame) } } diff --git a/Tests/SiteMiddlewareTests/AuthenticationMiddlewareTests.swift b/Tests/SiteMiddlewareTests/AuthenticationMiddlewareTests.swift index 88d7aceb..06fa1cfe 100644 --- a/Tests/SiteMiddlewareTests/AuthenticationMiddlewareTests.swift +++ b/Tests/SiteMiddlewareTests/AuthenticationMiddlewareTests.swift @@ -33,7 +33,7 @@ class AuthenticationMiddlewareTests: XCTestCase { } """.utf8) - var environment = ServerEnvironment.failing + var environment = ServerEnvironment.unimplemented environment.database.fetchAppleReceipt = { _ in pure(.mock) } environment.database.insertPlayer = { request in pure( @@ -127,7 +127,7 @@ class AuthenticationMiddlewareTests: XCTestCase { } """.utf8) - var environment = ServerEnvironment.failing + var environment = ServerEnvironment.unimplemented environment.database.fetchAppleReceipt = { _ in pure(.mock) } environment.database.insertPlayer = { request in pure( diff --git a/Tests/SiteMiddlewareTests/CurrentPlayerMiddlewareTests.swift b/Tests/SiteMiddlewareTests/CurrentPlayerMiddlewareTests.swift index 7af605df..ef7cfb8c 100644 --- a/Tests/SiteMiddlewareTests/CurrentPlayerMiddlewareTests.swift +++ b/Tests/SiteMiddlewareTests/CurrentPlayerMiddlewareTests.swift @@ -24,7 +24,7 @@ class CurrentPlayerMiddlewareTests: XCTestCase { )! ) - var environment = ServerEnvironment.failing + var environment = ServerEnvironment.unimplemented environment.database.fetchAppleReceipt = { _ in pure(.mock) } environment.database.fetchPlayerByAccessToken = { _ in pure(.blob) } diff --git a/Tests/UpgradeInterstitialFeatureTests/ShowUpgradeInterstitialEffectTests.swift b/Tests/UpgradeInterstitialFeatureTests/ShowUpgradeInterstitialEffectTests.swift index 8915f827..d927b3f4 100644 --- a/Tests/UpgradeInterstitialFeatureTests/ShowUpgradeInterstitialEffectTests.swift +++ b/Tests/UpgradeInterstitialFeatureTests/ShowUpgradeInterstitialEffectTests.swift @@ -7,20 +7,14 @@ import XCTest class ShowUpgradeInterstitialEffectTests: XCTestCase { func testBasics() { - var shows: [Bool] = [] - (1...20).forEach { count in - _ = Effect.showUpgradeInterstitial( + let shows = (1...20).map { count in + shouldShowInterstitial( + gamePlayedCount: count, gameContext: .solo, - isFullGamePurchased: false, serverConfig: update(.init()) { $0.upgradeInterstitial.playedSoloGamesTriggerCount = 10 $0.upgradeInterstitial.soloGameTriggerEvery = 4 - }, - playedGamesCount: { .init(value: count) } - ) - .sink( - receiveCompletion: { _ in }, - receiveValue: { shows.append($0) } + } ) } @@ -28,7 +22,9 @@ class ShowUpgradeInterstitialEffectTests: XCTestCase { shows, [ false, false, false, false, false, false, false, false, false, true, - false, false, false, true, false, false, false, true, false, false, + false, false, false, true, + false, false, false, true, + false, false, ] ) } diff --git a/Tests/UpgradeInterstitialFeatureTests/UpgradeInterstitialFeatureTests.swift b/Tests/UpgradeInterstitialFeatureTests/UpgradeInterstitialFeatureTests.swift index 1b16140d..d4b2e6fd 100644 --- a/Tests/UpgradeInterstitialFeatureTests/UpgradeInterstitialFeatureTests.swift +++ b/Tests/UpgradeInterstitialFeatureTests/UpgradeInterstitialFeatureTests.swift @@ -9,13 +9,15 @@ import XCTest @testable import ServerConfigClient +@MainActor class UpgradeInterstitialFeatureTests: XCTestCase { let scheduler = RunLoop.test - func testUpgrade() { - var paymentAdded: SKPayment? + func testUpgrade() async { + let paymentAdded = ActorIsolated(nil) - let observer = PassthroughSubject() + let observer = AsyncStream + .streamWithContinuation() let transactions = [ StoreKitClient.PaymentTransaction( @@ -35,20 +37,15 @@ class UpgradeInterstitialFeatureTests: XCTestCase { ) ] - var environment = UpgradeInterstitialEnvironment.failing + var environment = UpgradeInterstitialEnvironment.unimplemented environment.mainRunLoop = .immediate environment.serverConfig.config = { .init() } - environment.storeKit.addPayment = { payment in - paymentAdded = payment - return .none - } - environment.storeKit.observer = observer.eraseToEffect() + environment.storeKit.addPayment = { await paymentAdded.setValue($0.productIdentifier) } + environment.storeKit.observer = { observer.stream } environment.storeKit.fetchProducts = { _ in .init( - value: .init( - invalidProductIdentifiers: [], - products: [fullGameProduct] - ) + invalidProductIdentifiers: [], + products: [fullGameProduct] ) } @@ -58,32 +55,38 @@ class UpgradeInterstitialFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) + let task = await store.send(.task) - store.receive(.fullGameProductResponse(fullGameProduct)) { + await store.receive(.fullGameProductResponse(fullGameProduct)) { $0.fullGameProduct = fullGameProduct } - store.receive(.timerTick) { + await store.receive(.timerTick) { $0.secondsPassedCount = 1 } - store.send(.upgradeButtonTapped) { + await store.send(.upgradeButtonTapped) { $0.isPurchasing = true } - observer.send(.updatedTransactions(transactions)) - XCTAssertNoDifference(paymentAdded?.productIdentifier, "co.pointfree.isowords_testing.full_game") + observer.continuation.yield(.updatedTransactions(transactions)) + await paymentAdded.withValue { + XCTAssertNoDifference($0, "co.pointfree.isowords_testing.full_game") + } + + await store.receive(.paymentTransaction(.updatedTransactions(transactions))) + await store.receive(.delegate(.fullGamePurchased)) - store.receive(.paymentTransaction(.updatedTransactions(transactions))) - store.receive(.delegate(.fullGamePurchased)) + await task.cancel() } - func testWaitAndDismiss() { - var environment = UpgradeInterstitialEnvironment.failing + func testWaitAndDismiss() async { + var environment = UpgradeInterstitialEnvironment.unimplemented environment.mainRunLoop = self.scheduler.eraseToAnyScheduler() environment.serverConfig.config = { .init() } - environment.storeKit.observer = .none - environment.storeKit.fetchProducts = { _ in .none } + environment.storeKit.observer = { .finished } + environment.storeKit.fetchProducts = { _ in + .init(invalidProductIdentifiers: [], products: []) + } let store = TestStore( initialState: .init(), @@ -91,34 +94,36 @@ class UpgradeInterstitialFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) + await store.send(.task) - self.scheduler.advance(by: .seconds(1)) - store.receive(.timerTick) { $0.secondsPassedCount = 1 } + await self.scheduler.advance(by: .seconds(1)) + await store.receive(.timerTick) { $0.secondsPassedCount = 1 } - self.scheduler.advance(by: .seconds(15)) - store.receive(.timerTick) { $0.secondsPassedCount = 2 } - store.receive(.timerTick) { $0.secondsPassedCount = 3 } - store.receive(.timerTick) { $0.secondsPassedCount = 4 } - store.receive(.timerTick) { $0.secondsPassedCount = 5 } - store.receive(.timerTick) { $0.secondsPassedCount = 6 } - store.receive(.timerTick) { $0.secondsPassedCount = 7 } - store.receive(.timerTick) { $0.secondsPassedCount = 8 } - store.receive(.timerTick) { $0.secondsPassedCount = 9 } - store.receive(.timerTick) { $0.secondsPassedCount = 10 } + await self.scheduler.advance(by: .seconds(15)) + await store.receive(.timerTick) { $0.secondsPassedCount = 2 } + await store.receive(.timerTick) { $0.secondsPassedCount = 3 } + await store.receive(.timerTick) { $0.secondsPassedCount = 4 } + await store.receive(.timerTick) { $0.secondsPassedCount = 5 } + await store.receive(.timerTick) { $0.secondsPassedCount = 6 } + await store.receive(.timerTick) { $0.secondsPassedCount = 7 } + await store.receive(.timerTick) { $0.secondsPassedCount = 8 } + await store.receive(.timerTick) { $0.secondsPassedCount = 9 } + await store.receive(.timerTick) { $0.secondsPassedCount = 10 } - self.scheduler.run() + await self.scheduler.run() - store.send(.maybeLaterButtonTapped) - store.receive(.delegate(.close)) + await store.send(.maybeLaterButtonTapped) + await store.receive(.delegate(.close)) } - func testMaybeLater_Dismissable() { - var environment = UpgradeInterstitialEnvironment.failing + func testMaybeLater_Dismissable() async { + var environment = UpgradeInterstitialEnvironment.unimplemented environment.mainRunLoop = .immediate environment.serverConfig.config = { .init() } - environment.storeKit.observer = .none - environment.storeKit.fetchProducts = { _ in .none } + environment.storeKit.observer = { .finished } + environment.storeKit.fetchProducts = { _ in + .init(invalidProductIdentifiers: [], products: []) + } let store = TestStore( initialState: .init(isDismissable: true), @@ -126,9 +131,9 @@ class UpgradeInterstitialFeatureTests: XCTestCase { environment: environment ) - store.send(.onAppear) - store.send(.maybeLaterButtonTapped) - store.receive(.delegate(.close)) + await store.send(.task) + await store.send(.maybeLaterButtonTapped) + await store.receive(.delegate(.close)) } } @@ -144,9 +149,9 @@ let fullGameProduct = StoreKitClient.Product( ) extension UpgradeInterstitialEnvironment { - static let failing = Self( - mainRunLoop: .failing("mainRunLoop"), - serverConfig: .failing, - storeKit: .failing + static let unimplemented = Self( + mainRunLoop: .unimplemented("mainRunLoop"), + serverConfig: .unimplemented, + storeKit: .unimplemented ) } diff --git a/Tests/UpgradeInterstitialFeatureTests/__Snapshots__/UpgradeInterstitialViewTests/testBeginning.1.png b/Tests/UpgradeInterstitialFeatureTests/__Snapshots__/UpgradeInterstitialViewTests/testBeginning.1.png index 48687f9a..9318be4b 100644 --- a/Tests/UpgradeInterstitialFeatureTests/__Snapshots__/UpgradeInterstitialViewTests/testBeginning.1.png +++ b/Tests/UpgradeInterstitialFeatureTests/__Snapshots__/UpgradeInterstitialViewTests/testBeginning.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:26e2ca53efa2d3a56c6aadcefa870c2cee6b37b86c4ae820118892624be030e7 -size 831231 +oid sha256:084fc280a8ecf975691c709cfd17de57ab574c4d438ba36e1e217a35bfb1a090 +size 832282 diff --git a/Tests/UpgradeInterstitialFeatureTests/__Snapshots__/UpgradeInterstitialViewTests/testCountdownComplete.1.png b/Tests/UpgradeInterstitialFeatureTests/__Snapshots__/UpgradeInterstitialViewTests/testCountdownComplete.1.png index 8dadb71b..79d67a71 100644 --- a/Tests/UpgradeInterstitialFeatureTests/__Snapshots__/UpgradeInterstitialViewTests/testCountdownComplete.1.png +++ b/Tests/UpgradeInterstitialFeatureTests/__Snapshots__/UpgradeInterstitialViewTests/testCountdownComplete.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a117b6e2e73941b7ff1b122d65aebb08679c444e93143b6f7dc745e26d837af9 -size 826735 +oid sha256:395cfc6d33de844e05d9a7487594a4a6a1d17ab03f05b6934f63fdce9d049014 +size 829903 diff --git a/Tests/VerifyReceiptMiddlewareTests/VerifyReceiptMiddlewareTests.swift b/Tests/VerifyReceiptMiddlewareTests/VerifyReceiptMiddlewareTests.swift index 42810ae2..51f778aa 100644 --- a/Tests/VerifyReceiptMiddlewareTests/VerifyReceiptMiddlewareTests.swift +++ b/Tests/VerifyReceiptMiddlewareTests/VerifyReceiptMiddlewareTests.swift @@ -29,7 +29,7 @@ class VerifyReceiptMiddlewareTests: XCTestCase { var updatedPlayerId: Player.Id? var updatedAppleResponse: AppleVerifyReceiptResponse? - let environment = update(ServerEnvironment.failing) { + let environment = update(ServerEnvironment.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(.blob) } @@ -116,7 +116,7 @@ class VerifyReceiptMiddlewareTests: XCTestCase { var updatedPlayerId: Player.Id? var updatedData: AppleVerifyReceiptResponse? - let environment = update(ServerEnvironment.failing) { + let environment = update(ServerEnvironment.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(.blob) } @@ -203,7 +203,7 @@ class VerifyReceiptMiddlewareTests: XCTestCase { var updatedData: AppleVerifyReceiptResponse? let middleware = siteMiddleware( - environment: update(.failing) { + environment: update(.unimplemented) { $0.database.fetchPlayerByAccessToken = { _ in pure(.blob) } $0.itunes.verify = { data, environment in environment == .sandbox diff --git a/Tests/VocabMiddlewareTests/VocabMiddlewareTests.swift b/Tests/VocabMiddlewareTests/VocabMiddlewareTests.swift index adfab0b0..a86a5142 100644 --- a/Tests/VocabMiddlewareTests/VocabMiddlewareTests.swift +++ b/Tests/VocabMiddlewareTests/VocabMiddlewareTests.swift @@ -32,7 +32,7 @@ class VocabMiddlewareTests: XCTestCase { )! ) - var environment = ServerEnvironment.failing + var environment = ServerEnvironment.unimplemented environment.database.fetchPlayerByAccessToken = { _ in pure(.blob) } environment.database.fetchVocabLeaderboardWord = { wordId in XCTAssertNoDifference(