diff --git a/EssentialApp/EssentialApp.xcodeproj/project.pbxproj b/EssentialApp/EssentialApp.xcodeproj/project.pbxproj index 6b7dadb1..07f62d9b 100644 --- a/EssentialApp/EssentialApp.xcodeproj/project.pbxproj +++ b/EssentialApp/EssentialApp.xcodeproj/project.pbxproj @@ -508,6 +508,8 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -532,6 +534,8 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -556,7 +560,9 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EssentialApp.app/EssentialApp"; @@ -582,6 +588,8 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EssentialApp.app/EssentialApp"; diff --git a/EssentialApp/EssentialApp/CombineHelpers.swift b/EssentialApp/EssentialApp/CombineHelpers.swift index 0f11130b..c2464120 100644 --- a/EssentialApp/EssentialApp/CombineHelpers.swift +++ b/EssentialApp/EssentialApp/CombineHelpers.swift @@ -40,7 +40,10 @@ public extension HTTPClient { return Deferred { Future { completion in - task = self.get(from: url, completion: completion) + nonisolated(unsafe) let uncheckedCompletion = completion + task = self.get(from: url, completion: { + uncheckedCompletion($0) + }) } } .handleEvents(receiveCancel: { task?.cancel() }) @@ -229,7 +232,8 @@ extension AnyDispatchQueueScheduler { if store.contextQueue == .main, Thread.isMainThread { action() } else { - store.perform(action) + nonisolated(unsafe) let uncheckedAction = action + store.perform { uncheckedAction() } } return AnyCancellable {} } @@ -238,7 +242,8 @@ extension AnyDispatchQueueScheduler { if store.contextQueue == .main, Thread.isMainThread { action() } else { - store.perform(action) + nonisolated(unsafe) let uncheckedAction = action + store.perform { uncheckedAction() } } } @@ -246,7 +251,8 @@ extension AnyDispatchQueueScheduler { if store.contextQueue == .main, Thread.isMainThread { action() } else { - store.perform(action) + nonisolated(unsafe) let uncheckedAction = action + store.perform { uncheckedAction() } } } } diff --git a/EssentialApp/EssentialApp/CommentsUIComposer.swift b/EssentialApp/EssentialApp/CommentsUIComposer.swift index 63918780..d2d3062d 100644 --- a/EssentialApp/EssentialApp/CommentsUIComposer.swift +++ b/EssentialApp/EssentialApp/CommentsUIComposer.swift @@ -7,6 +7,7 @@ import Combine import EssentialFeed import EssentialFeediOS +@MainActor public final class CommentsUIComposer { private init() {} @@ -38,6 +39,7 @@ public final class CommentsUIComposer { } } +@MainActor final class CommentsViewAdapter: ResourceView { private weak var controller: ListViewController? diff --git a/EssentialApp/EssentialApp/FeedUIComposer.swift b/EssentialApp/EssentialApp/FeedUIComposer.swift index 0e018bc6..27539413 100644 --- a/EssentialApp/EssentialApp/FeedUIComposer.swift +++ b/EssentialApp/EssentialApp/FeedUIComposer.swift @@ -7,15 +7,16 @@ import Combine import EssentialFeed import EssentialFeediOS +@MainActor public final class FeedUIComposer { private init() {} private typealias FeedPresentationAdapter = LoadResourcePresentationAdapter, FeedViewAdapter> public static func feedComposedWith( - feedLoader: @escaping () -> AnyPublisher, Error>, - imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher, - selection: @escaping (FeedImage) -> Void = { _ in } + feedLoader: @MainActor @escaping () -> AnyPublisher, Error>, + imageLoader: @MainActor @escaping (URL) -> FeedImageDataLoader.Publisher, + selection: @MainActor @escaping (FeedImage) -> Void = { _ in } ) -> ListViewController { let presentationAdapter = FeedPresentationAdapter(loader: feedLoader) diff --git a/EssentialApp/EssentialApp/FeedViewAdapter.swift b/EssentialApp/EssentialApp/FeedViewAdapter.swift index bcadeeae..be0530c7 100644 --- a/EssentialApp/EssentialApp/FeedViewAdapter.swift +++ b/EssentialApp/EssentialApp/FeedViewAdapter.swift @@ -6,6 +6,7 @@ import UIKit import EssentialFeed import EssentialFeediOS +@MainActor final class FeedViewAdapter: ResourceView { private weak var controller: ListViewController? private let imageLoader: (URL) -> FeedImageDataLoader.Publisher diff --git a/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift b/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift index 80f1df18..6483a3fa 100644 --- a/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift +++ b/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift @@ -6,6 +6,7 @@ import Combine import EssentialFeed import EssentialFeediOS +@MainActor final class LoadResourcePresentationAdapter { private let loader: () -> AnyPublisher private var cancellable: Cancellable? diff --git a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift index 848c8622..034a08eb 100644 --- a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift @@ -9,6 +9,7 @@ import EssentialApp import EssentialFeed import EssentialFeediOS +@MainActor class CommentsUIIntegrationTests: XCTestCase { func test_commentsView_hasTitle() { @@ -107,18 +108,6 @@ class CommentsUIIntegrationTests: XCTestCase { assertThat(sut, isRendering: [comment]) } - func test_loadCommentsCompletion_dispatchesFromBackgroundToMainThread() { - let (sut, loader) = makeSUT() - sut.simulateAppearance() - - let exp = expectation(description: "Wait for background queue") - DispatchQueue.global().async { - loader.completeCommentsLoading(at: 0) - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - } - func test_loadCommentsCompletion_rendersErrorMessageOnErrorUntilNextReload() { let (sut, loader) = makeSUT() diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index f05dc7f5..9490c6d6 100644 --- a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -7,6 +7,7 @@ import EssentialFeed import EssentialFeediOS @testable import EssentialApp +@MainActor class FeedAcceptanceTests: XCTestCase { func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() throws { @@ -183,6 +184,7 @@ class FeedAcceptanceTests: XCTestCase { } +@MainActor extension CoreDataFeedStore { static var empty: CoreDataFeedStore { get throws { diff --git a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift index a8b28cb6..86d3d8c5 100644 --- a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift @@ -8,6 +8,7 @@ import EssentialApp import EssentialFeed import EssentialFeediOS +@MainActor class FeedUIIntegrationTests: XCTestCase { func test_feedView_hasTitle() { @@ -137,18 +138,6 @@ class FeedUIIntegrationTests: XCTestCase { assertThat(sut, isRendering: [image0]) } - func test_loadFeedCompletion_dispatchesFromBackgroundToMainThread() { - let (sut, loader) = makeSUT() - sut.simulateAppearance() - - let exp = expectation(description: "Wait for background queue") - DispatchQueue.global().async { - loader.completeFeedLoading(at: 0) - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - } - func test_loadFeedCompletion_rendersErrorMessageOnErrorUntilNextReload() { let (sut, loader) = makeSUT() @@ -225,20 +214,6 @@ class FeedUIIntegrationTests: XCTestCase { XCTAssertFalse(sut.isShowingLoadMoreFeedIndicator, "Expected no loading indicator once user initiated loading completes with error") } - func test_loadMoreCompletion_dispatchesFromBackgroundToMainThread() { - let (sut, loader) = makeSUT() - sut.simulateAppearance() - loader.completeFeedLoading(at: 0) - sut.simulateLoadMoreFeedAction() - - let exp = expectation(description: "Wait for background queue") - DispatchQueue.global().async { - loader.completeLoadMore() - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - } - func test_loadMoreCompletion_rendersErrorMessageOnError() { let (sut, loader) = makeSUT() sut.simulateAppearance() @@ -545,21 +520,6 @@ class FeedUIIntegrationTests: XCTestCase { XCTAssertNil(view?.renderedImage, "Expected no rendered image when an image load finishes after the view is not visible anymore") } - func test_loadImageDataCompletion_dispatchesFromBackgroundToMainThread() { - let (sut, loader) = makeSUT() - - sut.simulateAppearance() - loader.completeFeedLoading(with: [makeImage()]) - _ = sut.simulateFeedImageViewVisible(at: 0) - - let exp = expectation(description: "Wait for background queue") - DispatchQueue.global().async { - loader.completeImageLoading(with: self.anyImageData(), at: 0) - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - } - func test_feedImageView_doesNotLoadImageAgainUntilPreviousRequestCompletes() { let image = makeImage(url: URL(string: "http://url-0.com")!) let (sut, loader) = makeSUT() @@ -589,7 +549,7 @@ class FeedUIIntegrationTests: XCTestCase { // MARK: - Helpers private func makeSUT( - selection: @escaping (FeedImage) -> Void = { _ in }, + selection: @MainActor @escaping (FeedImage) -> Void = { _ in }, file: StaticString = #filePath, line: UInt = #line ) -> (sut: ListViewController, loader: LoaderSpy) { diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift index 7fec53f9..5fc2d880 100644 --- a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift +++ b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift @@ -9,6 +9,7 @@ import Combine extension FeedUIIntegrationTests { + @MainActor class LoaderSpy { // MARK: - FeedLoader diff --git a/EssentialApp/EssentialAppTests/Helpers/XCTestCase+MemoryLeakTracking.swift b/EssentialApp/EssentialAppTests/Helpers/XCTestCase+MemoryLeakTracking.swift index 30e3b423..94b6d976 100644 --- a/EssentialApp/EssentialAppTests/Helpers/XCTestCase+MemoryLeakTracking.swift +++ b/EssentialApp/EssentialAppTests/Helpers/XCTestCase+MemoryLeakTracking.swift @@ -5,6 +5,7 @@ import XCTest extension XCTestCase { + @MainActor func trackForMemoryLeaks(_ instance: AnyObject, file: StaticString = #filePath, line: UInt = #line) { addTeardownBlock { [weak instance] in XCTAssertNil(instance, "Instance should have been deallocated. Potential memory leak.", file: file, line: line) diff --git a/EssentialApp/EssentialAppTests/SceneDelegateTests.swift b/EssentialApp/EssentialAppTests/SceneDelegateTests.swift index a5dc02f2..0985ee5b 100644 --- a/EssentialApp/EssentialAppTests/SceneDelegateTests.swift +++ b/EssentialApp/EssentialAppTests/SceneDelegateTests.swift @@ -6,6 +6,7 @@ import XCTest import EssentialFeediOS @testable import EssentialApp +@MainActor class SceneDelegateTests: XCTestCase { func test_configureWindow_setsWindowAsKeyAndVisible() throws { diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index a048d9ac..3dd48fb5 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -1543,7 +1543,9 @@ SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -1577,6 +1579,8 @@ SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -1600,7 +1604,9 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -1624,6 +1630,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -1646,6 +1654,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -1668,6 +1678,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -1690,6 +1702,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -1712,6 +1726,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -1745,7 +1761,10 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -1779,6 +1798,9 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; @@ -1805,7 +1827,9 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -1831,6 +1855,8 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift index 8b935c4b..93e57fb5 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift @@ -4,8 +4,10 @@ import CoreData -public final class CoreDataFeedStore { +public final class CoreDataFeedStore: Sendable { private static let modelName = "FeedStore" + + @MainActor private static let model = NSManagedObjectModel.with(name: modelName, in: Bundle(for: CoreDataFeedStore.self)) private let container: NSPersistentContainer @@ -25,11 +27,16 @@ public final class CoreDataFeedStore { context == container.viewContext ? .main : .background } - public init(storeURL: URL, contextQueue: ContextQueue = .background) throws { + @MainActor + public convenience init(storeURL: URL, contextQueue: ContextQueue = .background) throws { guard let model = CoreDataFeedStore.model else { throw StoreError.modelNotFound } + try self.init(storeURL: storeURL, contextQueue: contextQueue, model: model) + } + + public init(storeURL: URL, contextQueue: ContextQueue = .background, model: NSManagedObjectModel) throws { do { container = try NSPersistentContainer.load(name: CoreDataFeedStore.modelName, model: model, url: storeURL) context = contextQueue == .main ? container.viewContext : container.newBackgroundContext() @@ -38,7 +45,7 @@ public final class CoreDataFeedStore { } } - public func perform(_ action: @escaping () -> Void) { + public func perform(_ action: @Sendable @escaping () -> Void) { context.perform(action) } diff --git a/EssentialFeed/EssentialFeed/Feed Feature/FeedImage.swift b/EssentialFeed/EssentialFeed/Feed Feature/FeedImage.swift index cb5f464d..febda84b 100644 --- a/EssentialFeed/EssentialFeed/Feed Feature/FeedImage.swift +++ b/EssentialFeed/EssentialFeed/Feed Feature/FeedImage.swift @@ -4,7 +4,7 @@ import Foundation -public struct FeedImage: Hashable { +public struct FeedImage: Hashable, Sendable { public let id: UUID public let description: String? public let location: String? diff --git a/EssentialFeed/EssentialFeed/Image Comments Presentation/ImageCommentsPresenter.swift b/EssentialFeed/EssentialFeed/Image Comments Presentation/ImageCommentsPresenter.swift index e5decd4c..90dc1665 100644 --- a/EssentialFeed/EssentialFeed/Image Comments Presentation/ImageCommentsPresenter.swift +++ b/EssentialFeed/EssentialFeed/Image Comments Presentation/ImageCommentsPresenter.swift @@ -4,11 +4,11 @@ import Foundation -public struct ImageCommentsViewModel { +public struct ImageCommentsViewModel: Sendable { public let comments: [ImageCommentViewModel] } -public struct ImageCommentViewModel: Hashable { +public struct ImageCommentViewModel: Hashable, Sendable { public let message: String public let date: String public let username: String diff --git a/EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift b/EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift index 06cb03ff..4a88ecc0 100644 --- a/EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift +++ b/EssentialFeed/EssentialFeed/Shared API Infra/URLSessionHTTPClient.swift @@ -21,7 +21,7 @@ public final class URLSessionHTTPClient: HTTPClient { } } - public func get(from url: URL, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask { + public func get(from url: URL, completion: @Sendable @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask { let task = session.dataTask(with: url) { data, response, error in completion(Result { if let error = error { diff --git a/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift b/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift index 32cf3d46..a3921b60 100644 --- a/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift +++ b/EssentialFeed/EssentialFeed/Shared API/HTTPClient.swift @@ -14,5 +14,5 @@ public protocol HTTPClient { /// The completion handler can be invoked in any thread. /// Clients are responsible to dispatch to appropriate threads, if needed. @discardableResult - func get(from url: URL, completion: @escaping (Result) -> Void) -> HTTPClientTask + func get(from url: URL, completion: @Sendable @escaping (Result) -> Void) -> HTTPClientTask } diff --git a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift index a0398cb2..047079fb 100644 --- a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift +++ b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift @@ -5,10 +5,11 @@ import XCTest import EssentialFeed +@MainActor class EssentialFeedAPIEndToEndTests: XCTestCase { - func test_endToEndTestServerGETFeedResult_matchesFixedTestAccountData() { - switch getFeedResult() { + func test_endToEndTestServerGETFeedResult_matchesFixedTestAccountData() async { + switch await getFeedResult() { case let .success(imageFeed)?: XCTAssertEqual(imageFeed.count, 8, "Expected 8 images in the test account image feed") XCTAssertEqual(imageFeed[0], expectedImage(at: 0)) @@ -28,8 +29,8 @@ class EssentialFeedAPIEndToEndTests: XCTestCase { } } - func test_endToEndTestServerGETFeedImageDataResult_matchesFixedTestAccountData() { - switch getFeedImageDataResult() { + func test_endToEndTestServerGETFeedImageDataResult_matchesFixedTestAccountData() async { + switch await getFeedImageDataResult() { case let .success(data)?: XCTAssertFalse(data.isEmpty, "Expected non-empty image data") @@ -43,45 +44,37 @@ class EssentialFeedAPIEndToEndTests: XCTestCase { // MARK: - Helpers - private func getFeedResult(file: StaticString = #filePath, line: UInt = #line) -> Swift.Result<[FeedImage], Error>? { + private func getFeedResult(file: StaticString = #filePath, line: UInt = #line) async -> Swift.Result<[FeedImage], Error>? { let client = ephemeralClient() - let exp = expectation(description: "Wait for load completion") - var receivedResult: Swift.Result<[FeedImage], Error>? - client.get(from: feedTestServerURL) { result in - receivedResult = result.flatMap { (data, response) in - do { - return .success(try FeedItemsMapper.map(data, from: response)) - } catch { - return .failure(error) - } + return await withCheckedContinuation { continuation in + client.get(from: feedTestServerURL) { result in + continuation.resume(returning: result.flatMap { (data, response) in + do { + return .success(try FeedItemsMapper.map(data, from: response)) + } catch { + return .failure(error) + } + }) } - exp.fulfill() } - wait(for: [exp], timeout: 5.0) - - return receivedResult } - private func getFeedImageDataResult(file: StaticString = #filePath, line: UInt = #line) -> Result? { + private func getFeedImageDataResult(file: StaticString = #filePath, line: UInt = #line) async -> Result? { let client = ephemeralClient() let url = feedTestServerURL.appendingPathComponent("73A7F70C-75DA-4C2E-B5A3-EED40DC53AA6/image") - let exp = expectation(description: "Wait for load completion") - var receivedResult: Result? - client.get(from: url) { result in - receivedResult = result.flatMap { (data, response) in - do { - return .success(try FeedImageDataMapper.map(data, from: response)) - } catch { - return .failure(error) - } + return await withCheckedContinuation { continuation in + client.get(from: url) { result in + continuation.resume(returning: result.flatMap { (data, response) in + do { + return .success(try FeedImageDataMapper.map(data, from: response)) + } catch { + return .failure(error) + } + }) } - exp.fulfill() } - wait(for: [exp], timeout: 5.0) - - return receivedResult } private var feedTestServerURL: URL { diff --git a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift index 5eb3f5ca..9fac2b50 100644 --- a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift +++ b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift @@ -5,16 +5,17 @@ import XCTest import EssentialFeed +@MainActor class EssentialFeedCacheIntegrationTests: XCTestCase { - override func setUp() { - super.setUp() + override func setUp() async throws { + try await super.setUp() setupEmptyStoreState() } - override func tearDown() { - super.tearDown() + override func tearDown() async throws { + try await super.tearDown() undoStoreSideEffects() } diff --git a/EssentialFeed/EssentialFeedTests/Feed API/FeedEndpointTests.swift b/EssentialFeed/EssentialFeedTests/Feed API/FeedEndpointTests.swift index 097a4e6e..7d2cb55f 100644 --- a/EssentialFeed/EssentialFeedTests/Feed API/FeedEndpointTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed API/FeedEndpointTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class FeedEndpointTests: XCTestCase { func test_feed_endpointURL() { diff --git a/EssentialFeed/EssentialFeedTests/Feed API/FeedImageDataMapperTests.swift b/EssentialFeed/EssentialFeedTests/Feed API/FeedImageDataMapperTests.swift index 57a723c3..ad512734 100644 --- a/EssentialFeed/EssentialFeedTests/Feed API/FeedImageDataMapperTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed API/FeedImageDataMapperTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class FeedImageDataMapperTests: XCTestCase { func test_map_throwsErrorOnNon200HTTPResponse() throws { diff --git a/EssentialFeed/EssentialFeedTests/Feed API/FeedItemsMapperTests.swift b/EssentialFeed/EssentialFeedTests/Feed API/FeedItemsMapperTests.swift index 6e89d67e..9b601c89 100644 --- a/EssentialFeed/EssentialFeedTests/Feed API/FeedItemsMapperTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed API/FeedItemsMapperTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class FeedItemsMapperTests: XCTestCase { func test_map_throwsErrorOnNon200HTTPResponse() throws { diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedImageDataUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedImageDataUseCaseTests.swift index 84f59b29..0d4dc6ae 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedImageDataUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedImageDataUseCaseTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class CacheFeedImageDataUseCaseTests: XCTestCase { func test_init_doesNotMessageStoreUponCreation() { diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 458b37b6..62572d81 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class CacheFeedUseCaseTests: XCTestCase { func test_init_doesNotMessageStoreUponCreation() { diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift index 623abd38..6c22f11f 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift @@ -5,35 +5,36 @@ import XCTest import EssentialFeed +@MainActor class CoreDataFeedImageDataStoreTests: XCTestCase, FeedImageDataStoreSpecs { func test_retrieveImageData_deliversNotFoundWhenEmpty() throws { try makeSUT { sut, imageDataURL in - self.assertThatRetrieveImageDataDeliversNotFoundOnEmptyCache(on: sut, imageDataURL: imageDataURL) + assertThatRetrieveImageDataDeliversNotFoundOnEmptyCache(on: sut, imageDataURL: imageDataURL) } } func test_retrieveImageData_deliversNotFoundWhenStoredDataURLDoesNotMatch() throws { try makeSUT { sut, imageDataURL in - self.assertThatRetrieveImageDataDeliversNotFoundWhenStoredDataURLDoesNotMatch(on: sut, imageDataURL: imageDataURL) + assertThatRetrieveImageDataDeliversNotFoundWhenStoredDataURLDoesNotMatch(on: sut, imageDataURL: imageDataURL) } } func test_retrieveImageData_deliversFoundDataWhenThereIsAStoredImageDataMatchingURL() throws { try makeSUT { sut, imageDataURL in - self.assertThatRetrieveImageDataDeliversFoundDataWhenThereIsAStoredImageDataMatchingURL(on: sut, imageDataURL: imageDataURL) + assertThatRetrieveImageDataDeliversFoundDataWhenThereIsAStoredImageDataMatchingURL(on: sut, imageDataURL: imageDataURL) } } func test_retrieveImageData_deliversLastInsertedValue() throws { try makeSUT { sut, imageDataURL in - self.assertThatRetrieveImageDataDeliversLastInsertedValueForURL(on: sut, imageDataURL: imageDataURL) + assertThatRetrieveImageDataDeliversLastInsertedValueForURL(on: sut, imageDataURL: imageDataURL) } } // - MARK: Helpers - private func makeSUT(_ test: @escaping (CoreDataFeedStore, URL) -> Void, file: StaticString = #filePath, line: UInt = #line) throws { + private func makeSUT(_ test: @Sendable @escaping (CoreDataFeedStore, URL) -> Void, file: StaticString = #filePath, line: UInt = #line) throws { let storeURL = URL(fileURLWithPath: "/dev/null") let sut = try CoreDataFeedStore(storeURL: storeURL) trackForMemoryLeaks(sut, file: file, line: line) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift index 643b6933..a85014d2 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift @@ -5,77 +5,78 @@ import XCTest import EssentialFeed +@MainActor class CoreDataFeedStoreTests: XCTestCase, FeedStoreSpecs { func test_retrieve_deliversEmptyOnEmptyCache() throws { try makeSUT { sut in - self.assertThatRetrieveDeliversEmptyOnEmptyCache(on: sut) + assertThatRetrieveDeliversEmptyOnEmptyCache(on: sut) } } func test_retrieve_hasNoSideEffectsOnEmptyCache() throws { try makeSUT { sut in - self.assertThatRetrieveHasNoSideEffectsOnEmptyCache(on: sut) + assertThatRetrieveHasNoSideEffectsOnEmptyCache(on: sut) } } func test_retrieve_deliversFoundValuesOnNonEmptyCache() throws { try makeSUT { sut in - self.assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on: sut) + assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on: sut) } } func test_retrieve_hasNoSideEffectsOnNonEmptyCache() throws { try makeSUT { sut in - self.assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) + assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) } } func test_insert_deliversNoErrorOnEmptyCache() throws { try makeSUT { sut in - self.assertThatInsertDeliversNoErrorOnEmptyCache(on: sut) + assertThatInsertDeliversNoErrorOnEmptyCache(on: sut) } } func test_insert_deliversNoErrorOnNonEmptyCache() throws { try makeSUT { sut in - self.assertThatInsertDeliversNoErrorOnNonEmptyCache(on: sut) + assertThatInsertDeliversNoErrorOnNonEmptyCache(on: sut) } } func test_insert_overridesPreviouslyInsertedCacheValues() throws { try makeSUT { sut in - self.assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) + assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) } } func test_delete_deliversNoErrorOnEmptyCache() throws { try makeSUT { sut in - self.assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) + assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) } } func test_delete_hasNoSideEffectsOnEmptyCache() throws { try makeSUT { sut in - self.assertThatDeleteHasNoSideEffectsOnEmptyCache(on: sut) + assertThatDeleteHasNoSideEffectsOnEmptyCache(on: sut) } } func test_delete_deliversNoErrorOnNonEmptyCache() throws { try makeSUT { sut in - self.assertThatDeleteDeliversNoErrorOnNonEmptyCache(on: sut) + assertThatDeleteDeliversNoErrorOnNonEmptyCache(on: sut) } } func test_delete_emptiesPreviouslyInsertedCache() throws { try makeSUT { sut in - self.assertThatDeleteEmptiesPreviouslyInsertedCache(on: sut) + assertThatDeleteEmptiesPreviouslyInsertedCache(on: sut) } } // - MARK: Helpers - private func makeSUT(_ test: @escaping (CoreDataFeedStore) -> Void, file: StaticString = #filePath, line: UInt = #line) throws { + private func makeSUT(_ test: @Sendable @escaping (CoreDataFeedStore) -> Void, file: StaticString = #filePath, line: UInt = #line) throws { let storeURL = URL(fileURLWithPath: "/dev/null") let sut = try CoreDataFeedStore(storeURL: storeURL) trackForMemoryLeaks(sut, file: file, line: line) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/XCTestCase+FeedImageDataStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/XCTestCase+FeedImageDataStoreSpecs.swift index eb63fcab..c36d31e2 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/XCTestCase+FeedImageDataStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/XCTestCase+FeedImageDataStoreSpecs.swift @@ -6,88 +6,80 @@ import XCTest import Foundation import EssentialFeed -extension FeedImageDataStoreSpecs where Self: XCTestCase { +func assertThatRetrieveImageDataDeliversNotFoundOnEmptyCache( + on sut: FeedImageDataStore, + imageDataURL: URL = anyURL(), + file: StaticString = #filePath, + line: UInt = #line +) { + expect(sut, toCompleteRetrievalWith: notFound(), for: imageDataURL, file: file, line: line) +} + +func assertThatRetrieveImageDataDeliversNotFoundWhenStoredDataURLDoesNotMatch( + on sut: FeedImageDataStore, + imageDataURL: URL = anyURL(), + file: StaticString = #filePath, + line: UInt = #line +) { + let nonMatchingURL = URL(string: "http://a-non-matching-url.com")! - func assertThatRetrieveImageDataDeliversNotFoundOnEmptyCache( - on sut: FeedImageDataStore, - imageDataURL: URL = anyURL(), - file: StaticString = #filePath, - line: UInt = #line - ) { - expect(sut, toCompleteRetrievalWith: notFound(), for: imageDataURL, file: file, line: line) - } + insert(anyData(), for: imageDataURL, into: sut, file: file, line: line) - func assertThatRetrieveImageDataDeliversNotFoundWhenStoredDataURLDoesNotMatch( - on sut: FeedImageDataStore, - imageDataURL: URL = anyURL(), - file: StaticString = #filePath, - line: UInt = #line - ) { - let nonMatchingURL = URL(string: "http://a-non-matching-url.com")! - - insert(anyData(), for: imageDataURL, into: sut, file: file, line: line) - - expect(sut, toCompleteRetrievalWith: notFound(), for: nonMatchingURL, file: file, line: line) - } + expect(sut, toCompleteRetrievalWith: notFound(), for: nonMatchingURL, file: file, line: line) +} + +func assertThatRetrieveImageDataDeliversFoundDataWhenThereIsAStoredImageDataMatchingURL( + on sut: FeedImageDataStore, + imageDataURL: URL = anyURL(), + file: StaticString = #filePath, + line: UInt = #line +) { + let storedData = anyData() - func assertThatRetrieveImageDataDeliversFoundDataWhenThereIsAStoredImageDataMatchingURL( - on sut: FeedImageDataStore, - imageDataURL: URL = anyURL(), - file: StaticString = #filePath, - line: UInt = #line - ) { - let storedData = anyData() - - insert(storedData, for: imageDataURL, into: sut, file: file, line: line) - - expect(sut, toCompleteRetrievalWith: found(storedData), for: imageDataURL, file: file, line: line) - } - - func assertThatRetrieveImageDataDeliversLastInsertedValueForURL( - on sut: FeedImageDataStore, - imageDataURL: URL = anyURL(), - file: StaticString = #filePath, - line: UInt = #line - ) { - let firstStoredData = Data("first".utf8) - let lastStoredData = Data("last".utf8) - - insert(firstStoredData, for: imageDataURL, into: sut, file: file, line: line) - insert(lastStoredData, for: imageDataURL, into: sut, file: file, line: line) - - expect(sut, toCompleteRetrievalWith: found(lastStoredData), for: imageDataURL, file: file, line: line) - } + insert(storedData, for: imageDataURL, into: sut, file: file, line: line) + expect(sut, toCompleteRetrievalWith: found(storedData), for: imageDataURL, file: file, line: line) } -extension FeedImageDataStoreSpecs where Self: XCTestCase { +func assertThatRetrieveImageDataDeliversLastInsertedValueForURL( + on sut: FeedImageDataStore, + imageDataURL: URL = anyURL(), + file: StaticString = #filePath, + line: UInt = #line +) { + let firstStoredData = Data("first".utf8) + let lastStoredData = Data("last".utf8) - func notFound() -> Result { - .success(.none) - } + insert(firstStoredData, for: imageDataURL, into: sut, file: file, line: line) + insert(lastStoredData, for: imageDataURL, into: sut, file: file, line: line) - func found(_ data: Data) -> Result { - .success(data) - } + expect(sut, toCompleteRetrievalWith: found(lastStoredData), for: imageDataURL, file: file, line: line) +} + +func notFound() -> Result { + .success(.none) +} + +func found(_ data: Data) -> Result { + .success(data) +} + +func expect(_ sut: FeedImageDataStore, toCompleteRetrievalWith expectedResult: Result, for url: URL, file: StaticString = #filePath, line: UInt = #line) { + let receivedResult = Result { try sut.retrieve(dataForURL: url) } - func expect(_ sut: FeedImageDataStore, toCompleteRetrievalWith expectedResult: Result, for url: URL, file: StaticString = #filePath, line: UInt = #line) { - let receivedResult = Result { try sut.retrieve(dataForURL: url) } + switch (receivedResult, expectedResult) { + case let (.success( receivedData), .success(expectedData)): + XCTAssertEqual(receivedData, expectedData, file: file, line: line) - switch (receivedResult, expectedResult) { - case let (.success( receivedData), .success(expectedData)): - XCTAssertEqual(receivedData, expectedData, file: file, line: line) - - default: - XCTFail("Expected \(expectedResult), got \(receivedResult) instead", file: file, line: line) - } + default: + XCTFail("Expected \(expectedResult), got \(receivedResult) instead", file: file, line: line) } - - func insert(_ data: Data, for url: URL, into sut: FeedImageDataStore, file: StaticString = #filePath, line: UInt = #line) { - do { - try sut.insert(data, for: url) - } catch { - XCTFail("Failed to insert image data: \(data) - error: \(error)", file: file, line: line) - } +} + +func insert(_ data: Data, for url: URL, into sut: FeedImageDataStore, file: StaticString = #filePath, line: UInt = #line) { + do { + try sut.insert(data, for: url) + } catch { + XCTFail("Failed to insert image data: \(data) - error: \(error)", file: file, line: line) } - } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/XCTestCase+FeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/XCTestCase+FeedStoreSpecs.swift index 7f32e9d0..66d6698c 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/XCTestCase+FeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs/XCTestCase+FeedStoreSpecs.swift @@ -4,129 +4,123 @@ import XCTest import EssentialFeed + +func assertThatRetrieveDeliversEmptyOnEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { + expect(sut, toRetrieve: .success(.none), file: file, line: line) +} + +func assertThatRetrieveHasNoSideEffectsOnEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { + expect(sut, toRetrieveTwice: .success(.none), file: file, line: line) +} -extension FeedStoreSpecs where Self: XCTestCase { +func assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { + let feed = uniqueImageFeed().local + let timestamp = Date() - func assertThatRetrieveDeliversEmptyOnEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { - expect(sut, toRetrieve: .success(.none), file: file, line: line) - } + insert((feed, timestamp), to: sut) - func assertThatRetrieveHasNoSideEffectsOnEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { - expect(sut, toRetrieveTwice: .success(.none), file: file, line: line) - } + expect(sut, toRetrieve: .success(CachedFeed(feed: feed, timestamp: timestamp)), file: file, line: line) +} + +func assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { + let feed = uniqueImageFeed().local + let timestamp = Date() - func assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { - let feed = uniqueImageFeed().local - let timestamp = Date() - - insert((feed, timestamp), to: sut) - - expect(sut, toRetrieve: .success(CachedFeed(feed: feed, timestamp: timestamp)), file: file, line: line) - } + insert((feed, timestamp), to: sut) - func assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { - let feed = uniqueImageFeed().local - let timestamp = Date() - - insert((feed, timestamp), to: sut) - - expect(sut, toRetrieveTwice: .success(CachedFeed(feed: feed, timestamp: timestamp)), file: file, line: line) - } + expect(sut, toRetrieveTwice: .success(CachedFeed(feed: feed, timestamp: timestamp)), file: file, line: line) +} + +func assertThatInsertDeliversNoErrorOnEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { + let insertionError = insert((uniqueImageFeed().local, Date()), to: sut) - func assertThatInsertDeliversNoErrorOnEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { - let insertionError = insert((uniqueImageFeed().local, Date()), to: sut) - - XCTAssertNil(insertionError, "Expected to insert cache successfully", file: file, line: line) - } + XCTAssertNil(insertionError, "Expected to insert cache successfully", file: file, line: line) +} + +func assertThatInsertDeliversNoErrorOnNonEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { + insert((uniqueImageFeed().local, Date()), to: sut) - func assertThatInsertDeliversNoErrorOnNonEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { - insert((uniqueImageFeed().local, Date()), to: sut) - - let insertionError = insert((uniqueImageFeed().local, Date()), to: sut) - - XCTAssertNil(insertionError, "Expected to override cache successfully", file: file, line: line) - } + let insertionError = insert((uniqueImageFeed().local, Date()), to: sut) - func assertThatInsertOverridesPreviouslyInsertedCacheValues(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { - insert((uniqueImageFeed().local, Date()), to: sut) - - let latestFeed = uniqueImageFeed().local - let latestTimestamp = Date() - insert((latestFeed, latestTimestamp), to: sut) - - expect(sut, toRetrieve: .success(CachedFeed(feed: latestFeed, timestamp: latestTimestamp)), file: file, line: line) - } + XCTAssertNil(insertionError, "Expected to override cache successfully", file: file, line: line) +} + +func assertThatInsertOverridesPreviouslyInsertedCacheValues(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { + insert((uniqueImageFeed().local, Date()), to: sut) - func assertThatDeleteDeliversNoErrorOnEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { - let deletionError = deleteCache(from: sut) - - XCTAssertNil(deletionError, "Expected empty cache deletion to succeed", file: file, line: line) - } + let latestFeed = uniqueImageFeed().local + let latestTimestamp = Date() + insert((latestFeed, latestTimestamp), to: sut) - func assertThatDeleteHasNoSideEffectsOnEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { - deleteCache(from: sut) - - expect(sut, toRetrieve: .success(.none), file: file, line: line) - } + expect(sut, toRetrieve: .success(CachedFeed(feed: latestFeed, timestamp: latestTimestamp)), file: file, line: line) +} + +func assertThatDeleteDeliversNoErrorOnEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { + let deletionError = deleteCache(from: sut) - func assertThatDeleteDeliversNoErrorOnNonEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { - insert((uniqueImageFeed().local, Date()), to: sut) - - let deletionError = deleteCache(from: sut) - - XCTAssertNil(deletionError, "Expected non-empty cache deletion to succeed", file: file, line: line) - } + XCTAssertNil(deletionError, "Expected empty cache deletion to succeed", file: file, line: line) +} + +func assertThatDeleteHasNoSideEffectsOnEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { + deleteCache(from: sut) - func assertThatDeleteEmptiesPreviouslyInsertedCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { - insert((uniqueImageFeed().local, Date()), to: sut) - - deleteCache(from: sut) - - expect(sut, toRetrieve: .success(.none), file: file, line: line) - } + expect(sut, toRetrieve: .success(.none), file: file, line: line) +} + +func assertThatDeleteDeliversNoErrorOnNonEmptyCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { + insert((uniqueImageFeed().local, Date()), to: sut) + + let deletionError = deleteCache(from: sut) + XCTAssertNil(deletionError, "Expected non-empty cache deletion to succeed", file: file, line: line) } -extension FeedStoreSpecs where Self: XCTestCase { - @discardableResult - func insert(_ cache: (feed: [LocalFeedImage], timestamp: Date), to sut: FeedStore) -> Error? { - do { - try sut.insert(cache.feed, timestamp: cache.timestamp) - return nil - } catch { - return error - } - } +func assertThatDeleteEmptiesPreviouslyInsertedCache(on sut: FeedStore, file: StaticString = #filePath, line: UInt = #line) { + insert((uniqueImageFeed().local, Date()), to: sut) - @discardableResult - func deleteCache(from sut: FeedStore) -> Error? { - do { - try sut.deleteCachedFeed() - return nil - } catch { - return error - } - } + deleteCache(from: sut) - func expect(_ sut: FeedStore, toRetrieveTwice expectedResult: Result, file: StaticString = #filePath, line: UInt = #line) { - expect(sut, toRetrieve: expectedResult, file: file, line: line) - expect(sut, toRetrieve: expectedResult, file: file, line: line) + expect(sut, toRetrieve: .success(.none), file: file, line: line) +} + +@discardableResult +func insert(_ cache: (feed: [LocalFeedImage], timestamp: Date), to sut: FeedStore) -> Error? { + do { + try sut.insert(cache.feed, timestamp: cache.timestamp) + return nil + } catch { + return error + } +} + +@discardableResult +func deleteCache(from sut: FeedStore) -> Error? { + do { + try sut.deleteCachedFeed() + return nil + } catch { + return error } +} + +func expect(_ sut: FeedStore, toRetrieveTwice expectedResult: Result, file: StaticString = #filePath, line: UInt = #line) { + expect(sut, toRetrieve: expectedResult, file: file, line: line) + expect(sut, toRetrieve: expectedResult, file: file, line: line) +} + +func expect(_ sut: FeedStore, toRetrieve expectedResult: Result, file: StaticString = #filePath, line: UInt = #line) { + let retrievedResult = Result { try sut.retrieve() } - func expect(_ sut: FeedStore, toRetrieve expectedResult: Result, file: StaticString = #filePath, line: UInt = #line) { - let retrievedResult = Result { try sut.retrieve() } + switch (expectedResult, retrievedResult) { + case (.success(.none), .success(.none)), + (.failure, .failure): + break + + case let (.success(.some(expected)), .success(.some(retrieved))): + XCTAssertEqual(retrieved.feed, expected.feed, file: file, line: line) + XCTAssertEqual(retrieved.timestamp, expected.timestamp, file: file, line: line) - switch (expectedResult, retrievedResult) { - case (.success(.none), .success(.none)), - (.failure, .failure): - break - - case let (.success(.some(expected)), .success(.some(retrieved))): - XCTAssertEqual(retrieved.feed, expected.feed, file: file, line: line) - XCTAssertEqual(retrieved.timestamp, expected.timestamp, file: file, line: line) - - default: - XCTFail("Expected to retrieve \(expectedResult), got \(retrievedResult) instead", file: file, line: line) - } + default: + XCTFail("Expected to retrieve \(expectedResult), got \(retrievedResult) instead", file: file, line: line) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedImageDataStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedImageDataStoreTests.swift index 049db611..1719d251 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedImageDataStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedImageDataStoreTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class InMemoryFeedImageDataStoreTests: XCTestCase, FeedImageDataStoreSpecs { func test_retrieveImageData_deliversNotFoundWhenEmpty() throws { diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedStoreTests.swift index ca7b7735..fe17d4b0 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedStoreTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class InMemoryFeedStoreTests: XCTestCase, FeedStoreSpecs { func test_retrieve_deliversEmptyOnEmptyCache() throws { diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index 0ecbac37..477e249a 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class LoadFeedFromCacheUseCaseTests: XCTestCase { func test_init_doesNotMessageStoreUponCreation() { diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedImageDataFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedImageDataFromCacheUseCaseTests.swift index 2f461c03..3bb29e6d 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedImageDataFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedImageDataFromCacheUseCaseTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class LoadFeedImageDataFromCacheUseCaseTests: XCTestCase { func test_init_doesNotMessageStoreUponCreation() { diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift index f1f08741..20e0cff1 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class ValidateFeedCacheUseCaseTests: XCTestCase { func test_init_doesNotMessageStoreUponCreation() { diff --git a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift index 886e6429..6d9df25e 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class FeedImagePresenterTests: XCTestCase { func test_map_createsViewModel() { diff --git a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedLocalizationTests.swift b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedLocalizationTests.swift index a77109dc..6929070f 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedLocalizationTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedLocalizationTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor final class FeedLocalizationTests: XCTestCase { func test_localizedStrings_haveKeysAndValuesForAllSupportedLocalizations() { diff --git a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift index 08924db5..34d94817 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class FeedPresenterTests: XCTestCase { func test_title_isLocalized() { diff --git a/EssentialFeed/EssentialFeedTests/Helpers/XCTestCase+MemoryLeakTracking.swift b/EssentialFeed/EssentialFeedTests/Helpers/XCTestCase+MemoryLeakTracking.swift index 446c4feb..2c7a4c23 100644 --- a/EssentialFeed/EssentialFeedTests/Helpers/XCTestCase+MemoryLeakTracking.swift +++ b/EssentialFeed/EssentialFeedTests/Helpers/XCTestCase+MemoryLeakTracking.swift @@ -5,6 +5,7 @@ import XCTest extension XCTestCase { + @MainActor func trackForMemoryLeaks(_ instance: AnyObject, file: StaticString = #filePath, line: UInt = #line) { addTeardownBlock { [weak instance] in XCTAssertNil(instance, "Instance should have been deallocated. Potential memory leak.", file: file, line: line) diff --git a/EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsEndpointTests.swift b/EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsEndpointTests.swift index ab66a269..59184f23 100644 --- a/EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsEndpointTests.swift +++ b/EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsEndpointTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class ImageCommentsEndpointTests: XCTestCase { func test_imageComments_endpointURL() { diff --git a/EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsMapperTests.swift b/EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsMapperTests.swift index c1b6bc44..3d45c75e 100644 --- a/EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsMapperTests.swift +++ b/EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsMapperTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class ImageCommentsMapperTests: XCTestCase { func test_map_throwsErrorOnNon2xxHTTPResponse() throws { diff --git a/EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsLocalizationTests.swift b/EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsLocalizationTests.swift index 94d56406..4b7baaaf 100644 --- a/EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsLocalizationTests.swift +++ b/EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsLocalizationTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class ImageCommentsLocalizationTests: XCTestCase { func test_localizedStrings_haveKeysAndValuesForAllSupportedLocalizations() { diff --git a/EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsPresenterTests.swift b/EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsPresenterTests.swift index f5a43d1d..43690992 100644 --- a/EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsPresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Image Comments Presentation/ImageCommentsPresenterTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class ImageCommentsPresenterTests: XCTestCase { func test_title_isLocalized() { diff --git a/EssentialFeed/EssentialFeedTests/Shared API Infra/Helpers/URLProtocolStub.swift b/EssentialFeed/EssentialFeedTests/Shared API Infra/Helpers/URLProtocolStub.swift index 19000b34..6a395254 100644 --- a/EssentialFeed/EssentialFeedTests/Shared API Infra/Helpers/URLProtocolStub.swift +++ b/EssentialFeed/EssentialFeedTests/Shared API Infra/Helpers/URLProtocolStub.swift @@ -3,54 +3,41 @@ // import Foundation +import Synchronization class URLProtocolStub: URLProtocol { private struct Stub { - let onStartLoading: (URLProtocolStub) -> Void + let data: Data? + let response: URLResponse? + let error: Error? + let shouldComplete: Bool + let onStartLoading: @MainActor (URLRequest) -> Void } - private static var _stub: Stub? - private static var stub: Stub? { - get { return queue.sync { _stub } } - set { queue.sync { _stub = newValue } } - } - - private static let queue = DispatchQueue(label: "URLProtocolStub.queue") + private static let stub = Mutex(nil) static func stub(data: Data?, response: URLResponse?, error: Error?) { - stub = Stub(onStartLoading: { urlProtocol in - guard let client = urlProtocol.client else { return } - - if let data { - client.urlProtocol(urlProtocol, didLoad: data) - } - - if let response { - client.urlProtocol(urlProtocol, didReceive: response, cacheStoragePolicy: .notAllowed) - } - - if let error { - client.urlProtocol(urlProtocol, didFailWithError: error) - } else { - client.urlProtocolDidFinishLoading(urlProtocol) - } - }) + stub.withLock { stub in + stub = Stub(data: data, response: response, error: error, shouldComplete: true, onStartLoading: { _ in }) + } } - static func observeRequests(observer: @escaping (URLRequest) -> Void) { - stub = Stub(onStartLoading: { urlProtocol in - urlProtocol.client?.urlProtocolDidFinishLoading(urlProtocol) - - observer(urlProtocol.request) - }) + static func observeRequests(observer: @MainActor @escaping (URLRequest) -> Void) { + stub.withLock { stub in + stub = Stub(data: Data(), response: HTTPURLResponse(), error: nil, shouldComplete: true, onStartLoading: observer) + } } - static func onStartLoading(observer: @escaping () -> Void) { - stub = Stub(onStartLoading: { _ in observer() }) + static func onStartLoading(observer: @MainActor @escaping () -> Void) { + stub.withLock { stub in + stub = Stub(data: nil, response: nil, error: nil, shouldComplete: false, onStartLoading: { _ in observer() }) + } } static func removeStub() { - stub = nil + stub.withLock { stub in + stub = nil + } } override class func canInit(with request: URLRequest) -> Bool { @@ -62,7 +49,27 @@ class URLProtocolStub: URLProtocol { } override func startLoading() { - URLProtocolStub.stub?.onStartLoading(self) + guard let stub = URLProtocolStub.stub.withLock({ $0 }) else { return } + + Task { @MainActor [request] in + stub.onStartLoading(request) + } + + guard let client = self.client else { return } + + if let data = stub.data { + client.urlProtocol(self, didLoad: data) + } + + if let response = stub.response { + client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + } + + if let error = stub.error { + client.urlProtocol(self, didFailWithError: error) + } else if stub.shouldComplete { + client.urlProtocolDidFinishLoading(self) + } } override func stopLoading() {} diff --git a/EssentialFeed/EssentialFeedTests/Shared API Infra/URLSessionHTTPClientTests.swift b/EssentialFeed/EssentialFeedTests/Shared API Infra/URLSessionHTTPClientTests.swift index 944efc13..2e4262f5 100644 --- a/EssentialFeed/EssentialFeedTests/Shared API Infra/URLSessionHTTPClientTests.swift +++ b/EssentialFeed/EssentialFeedTests/Shared API Infra/URLSessionHTTPClientTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class URLSessionHTTPClientTests: XCTestCase { override func tearDown() { @@ -28,50 +29,50 @@ class URLSessionHTTPClientTests: XCTestCase { wait(for: [exp], timeout: 1.0) } - func test_cancelGetFromURLTask_cancelsURLRequest() { + func test_cancelGetFromURLTask_cancelsURLRequest() async { var task: HTTPClientTask? URLProtocolStub.onStartLoading { task?.cancel() } - let receivedError = resultErrorFor(taskHandler: { task = $0 }) as NSError? + let receivedError = await resultErrorFor(taskHandler: { task = $0 }) as NSError? XCTAssertEqual(receivedError?.code, URLError.cancelled.rawValue) } - func test_getFromURL_failsOnRequestError() { + func test_getFromURL_failsOnRequestError() async { let requestError = anyNSError() - let receivedError = resultErrorFor((data: nil, response: nil, error: requestError)) + let receivedError = await resultErrorFor((data: nil, response: nil, error: requestError)) XCTAssertNotNil(receivedError) } - func test_getFromURL_failsOnAllInvalidRepresentationCases() { - XCTAssertNotNil(resultErrorFor((data: nil, response: nil, error: nil))) - XCTAssertNotNil(resultErrorFor((data: nil, response: nonHTTPURLResponse(), error: nil))) - XCTAssertNotNil(resultErrorFor((data: anyData(), response: nil, error: nil))) - XCTAssertNotNil(resultErrorFor((data: anyData(), response: nil, error: anyNSError()))) - XCTAssertNotNil(resultErrorFor((data: nil, response: nonHTTPURLResponse(), error: anyNSError()))) - XCTAssertNotNil(resultErrorFor((data: nil, response: anyHTTPURLResponse(), error: anyNSError()))) - XCTAssertNotNil(resultErrorFor((data: anyData(), response: nonHTTPURLResponse(), error: anyNSError()))) - XCTAssertNotNil(resultErrorFor((data: anyData(), response: anyHTTPURLResponse(), error: anyNSError()))) - XCTAssertNotNil(resultErrorFor((data: anyData(), response: nonHTTPURLResponse(), error: nil))) + func test_getFromURL_failsOnAllInvalidRepresentationCases() async { + assertNotNil(await resultErrorFor((data: nil, response: nil, error: nil))) + assertNotNil(await resultErrorFor((data: nil, response: nonHTTPURLResponse(), error: nil))) + assertNotNil(await resultErrorFor((data: anyData(), response: nil, error: nil))) + assertNotNil(await resultErrorFor((data: anyData(), response: nil, error: anyNSError()))) + assertNotNil(await resultErrorFor((data: nil, response: nonHTTPURLResponse(), error: anyNSError()))) + assertNotNil(await resultErrorFor((data: nil, response: anyHTTPURLResponse(), error: anyNSError()))) + assertNotNil(await resultErrorFor((data: anyData(), response: nonHTTPURLResponse(), error: anyNSError()))) + assertNotNil(await resultErrorFor((data: anyData(), response: anyHTTPURLResponse(), error: anyNSError()))) + assertNotNil(await resultErrorFor((data: anyData(), response: nonHTTPURLResponse(), error: nil))) } - func test_getFromURL_succeedsOnHTTPURLResponseWithData() { + func test_getFromURL_succeedsOnHTTPURLResponseWithData() async { let data = anyData() let response = anyHTTPURLResponse() - let receivedValues = resultValuesFor((data: data, response: response, error: nil)) + let receivedValues = await resultValuesFor((data: data, response: response, error: nil)) XCTAssertEqual(receivedValues?.data, data) XCTAssertEqual(receivedValues?.response.url, response.url) XCTAssertEqual(receivedValues?.response.statusCode, response.statusCode) } - func test_getFromURL_succeedsWithEmptyDataOnHTTPURLResponseWithNilData() { + func test_getFromURL_succeedsWithEmptyDataOnHTTPURLResponseWithNilData() async { let response = anyHTTPURLResponse() - let receivedValues = resultValuesFor((data: nil, response: response, error: nil)) + let receivedValues = await resultValuesFor((data: nil, response: response, error: nil)) let emptyData = Data() XCTAssertEqual(receivedValues?.data, emptyData) @@ -91,8 +92,8 @@ class URLSessionHTTPClientTests: XCTestCase { return sut } - private func resultValuesFor(_ values: (data: Data?, response: URLResponse?, error: Error?), file: StaticString = #filePath, line: UInt = #line) -> (data: Data, response: HTTPURLResponse)? { - let result = resultFor(values, file: file, line: line) + private func resultValuesFor(_ values: (data: Data?, response: URLResponse?, error: Error?), file: StaticString = #filePath, line: UInt = #line) async -> (data: Data, response: HTTPURLResponse)? { + let result = await resultFor(values, file: file, line: line) switch result { case let .success(values): @@ -103,8 +104,8 @@ class URLSessionHTTPClientTests: XCTestCase { } } - private func resultErrorFor(_ values: (data: Data?, response: URLResponse?, error: Error?)? = nil, taskHandler: (HTTPClientTask) -> Void = { _ in }, file: StaticString = #filePath, line: UInt = #line) -> Error? { - let result = resultFor(values, taskHandler: taskHandler, file: file, line: line) + private func resultErrorFor(_ values: (data: Data?, response: URLResponse?, error: Error?)? = nil, taskHandler: (HTTPClientTask) -> Void = { _ in }, file: StaticString = #filePath, line: UInt = #line) async -> Error? { + let result = await resultFor(values, taskHandler: taskHandler, file: file, line: line) switch result { case let .failure(error): @@ -115,20 +116,16 @@ class URLSessionHTTPClientTests: XCTestCase { } } - private func resultFor(_ values: (data: Data?, response: URLResponse?, error: Error?)?, taskHandler: (HTTPClientTask) -> Void = { _ in }, file: StaticString = #filePath, line: UInt = #line) -> HTTPClient.Result { + private func resultFor(_ values: (data: Data?, response: URLResponse?, error: Error?)?, taskHandler: (HTTPClientTask) -> Void = { _ in }, file: StaticString = #filePath, line: UInt = #line) async -> HTTPClient.Result { values.map { URLProtocolStub.stub(data: $0, response: $1, error: $2) } let sut = makeSUT(file: file, line: line) - let exp = expectation(description: "Wait for completion") - var receivedResult: HTTPClient.Result! - taskHandler(sut.get(from: anyURL()) { result in - receivedResult = result - exp.fulfill() - }) - - wait(for: [exp], timeout: 1.0) - return receivedResult + return await withCheckedContinuation { continuation in + taskHandler(sut.get(from: anyURL()) { result in + continuation.resume(returning: result) + }) + } } private func anyHTTPURLResponse() -> HTTPURLResponse { @@ -140,3 +137,7 @@ class URLSessionHTTPClientTests: XCTestCase { } } + +private func assertNotNil(_ value: Any?, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) { + XCTAssertNotNil(value, message(), file: file, line: line) +} diff --git a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift index bf6aa085..711dd772 100644 --- a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class LoadResourcePresenterTests: XCTestCase { func test_init_doesNotSendMessagesToView() { diff --git a/EssentialFeed/EssentialFeedTests/Shared Presentation/SharedLocalizationTests.swift b/EssentialFeed/EssentialFeedTests/Shared Presentation/SharedLocalizationTests.swift index 3bf40fa1..833c698a 100644 --- a/EssentialFeed/EssentialFeedTests/Shared Presentation/SharedLocalizationTests.swift +++ b/EssentialFeed/EssentialFeedTests/Shared Presentation/SharedLocalizationTests.swift @@ -5,6 +5,7 @@ import XCTest import EssentialFeed +@MainActor class SharedLocalizationTests: XCTestCase { func test_localizedStrings_haveKeysAndValuesForAllSupportedLocalizations() { diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/LoadMoreCellController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/LoadMoreCellController.swift index ba6344d6..863c3c09 100644 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/LoadMoreCellController.swift +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/LoadMoreCellController.swift @@ -27,9 +27,11 @@ public class LoadMoreCellController: NSObject, UITableViewDataSource, UITableVie reloadIfNeeded() offsetObserver = tableView.observe(\.contentOffset, options: .new) { [weak self] (tableView, _) in - guard tableView.isDragging else { return } - - self?.reloadIfNeeded() + MainActor.assumeIsolated { + guard tableView.isDragging else { return } + + self?.reloadIfNeeded() + } } } diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UIView+Shimmering.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UIView+Shimmering.swift index c171757b..64287a6b 100644 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UIView+Shimmering.swift +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Helpers/UIView+Shimmering.swift @@ -30,6 +30,10 @@ extension UIView { private class ShimmeringLayer: CAGradientLayer { private var observer: Any? + nonisolated override init() { super.init() } + nonisolated override init(layer: Any) { super.init(layer: layer) } + nonisolated required init?(coder: NSCoder) { super.init(coder: coder) } + convenience init(size: CGSize) { self.init() @@ -49,7 +53,7 @@ extension UIView { animation.repeatCount = .infinity add(animation, forKey: "shimmer") - observer = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { [weak self] _ in + observer = NotificationCenter.default.addObserver(of: UIApplication.shared, for: .willEnterForeground) { [weak self] _ in self?.add(animation, forKey: "shimmer") } } diff --git a/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/CellController.swift b/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/CellController.swift index 4795e1e4..bc2b79bf 100644 --- a/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/CellController.swift +++ b/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/CellController.swift @@ -5,12 +5,12 @@ import UIKit public struct CellController { - let id: AnyHashable + let id: any Hashable & Sendable let dataSource: UITableViewDataSource let delegate: UITableViewDelegate? let dataSourcePrefetching: UITableViewDataSourcePrefetching? - public init(id: AnyHashable, _ dataSource: UITableViewDataSource) { + public init(id: any Hashable & Sendable, _ dataSource: UITableViewDataSource) { self.id = id self.dataSource = dataSource self.delegate = dataSource as? UITableViewDelegate @@ -18,14 +18,15 @@ public struct CellController { } } -extension CellController: Equatable { - public static func == (lhs: CellController, rhs: CellController) -> Bool { - lhs.id == rhs.id +extension CellController: nonisolated Equatable { + public nonisolated static func == (lhs: CellController, rhs: CellController) -> Bool { + AnyHashable(lhs.id) == AnyHashable(rhs.id) } } -extension CellController: Hashable { - public func hash(into hasher: inout Hasher) { +extension CellController: nonisolated Hashable { + public nonisolated func hash(into hasher: inout Hasher) { + let id = AnyHashable(self.id) hasher.combine(id) } } diff --git a/EssentialFeed/EssentialFeediOSTests/Feed UI/FeedSnapshotTests.swift b/EssentialFeed/EssentialFeediOSTests/Feed UI/FeedSnapshotTests.swift index 59abbf24..0cfe647c 100644 --- a/EssentialFeed/EssentialFeediOSTests/Feed UI/FeedSnapshotTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/Feed UI/FeedSnapshotTests.swift @@ -6,6 +6,7 @@ import XCTest import EssentialFeediOS @testable import EssentialFeed +@MainActor class FeedSnapshotTests: XCTestCase { func test_feedWithContent() { diff --git a/EssentialFeed/EssentialFeediOSTests/Image Comments UI/ImageCommentsSnapshotTests.swift b/EssentialFeed/EssentialFeediOSTests/Image Comments UI/ImageCommentsSnapshotTests.swift index 19713325..85fb2a8e 100644 --- a/EssentialFeed/EssentialFeediOSTests/Image Comments UI/ImageCommentsSnapshotTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/Image Comments UI/ImageCommentsSnapshotTests.swift @@ -6,6 +6,7 @@ import XCTest import EssentialFeediOS @testable import EssentialFeed +@MainActor class ImageCommentsSnapshotTests: XCTestCase { func test_listWithComments() { diff --git a/EssentialFeed/EssentialFeediOSTests/Shared UI/ListSnapshotTests.swift b/EssentialFeed/EssentialFeediOSTests/Shared UI/ListSnapshotTests.swift index d2523e1e..ca1c1b28 100644 --- a/EssentialFeed/EssentialFeediOSTests/Shared UI/ListSnapshotTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/Shared UI/ListSnapshotTests.swift @@ -6,6 +6,7 @@ import XCTest import EssentialFeediOS @testable import EssentialFeed +@MainActor class ListSnapshotTests: XCTestCase { func test_emptyList() {