From 3fb2bb61ba13d7b8df33e4d335a51bf4e53cec09 Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Fri, 22 Mar 2024 15:06:05 +0200 Subject: [PATCH 1/7] Enable CoreData concurrency debug in EssentialApp scheme --- .../xcshareddata/xcschemes/EssentialApp.xcscheme | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/EssentialApp.xcscheme b/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/EssentialApp.xcscheme index cae4c28e..36e4d2b5 100644 --- a/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/EssentialApp.xcscheme +++ b/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/EssentialApp.xcscheme @@ -72,6 +72,12 @@ ReferencedContainer = "container:EssentialApp.xcodeproj"> + + + + Date: Fri, 22 Mar 2024 15:25:38 +0200 Subject: [PATCH 2/7] Move context.perform call up --- .../xcschemes/EssentialFeed.xcscheme | 6 + .../CoreData/CoreDataFeedStore.swift | 4 + .../CoreDataFeedImageDataStoreTests.swift | 127 ++++++++++-------- .../Feed Cache/CoreDataFeedStoreTests.swift | 76 ++++++----- 4 files changed, 119 insertions(+), 94 deletions(-) diff --git a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeed.xcscheme b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeed.xcscheme index af15fe32..776f6051 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeed.xcscheme +++ b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeed.xcscheme @@ -80,6 +80,12 @@ ReferencedContainer = "container:EssentialFeed.xcodeproj"> + + + + Void) { + context.perform(action) + } + private func cleanUpReferencesToPersistentStores() { context.performAndWait { let coordinator = self.container.persistentStoreCoordinator diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift index 6f1f1194..b49a2a4c 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift @@ -8,84 +8,93 @@ import EssentialFeed class CoreDataFeedImageDataStoreTests: XCTestCase { func test_retrieveImageData_deliversNotFoundWhenEmpty() throws { - let sut = try makeSUT() - - expect(sut, toCompleteRetrievalWith: notFound(), for: anyURL()) + try makeSUT { sut in + expect(sut, toCompleteRetrievalWith: notFound(), for: anyURL()) + } } func test_retrieveImageData_deliversNotFoundWhenStoredDataURLDoesNotMatch() throws { - let sut = try makeSUT() - let url = URL(string: "http://a-url.com")! - let nonMatchingURL = URL(string: "http://another-url.com")! - - insert(anyData(), for: url, into: sut) - - expect(sut, toCompleteRetrievalWith: notFound(), for: nonMatchingURL) + try makeSUT { sut in + let url = URL(string: "http://a-url.com")! + let nonMatchingURL = URL(string: "http://another-url.com")! + + insert(anyData(), for: url, into: sut) + + expect(sut, toCompleteRetrievalWith: notFound(), for: nonMatchingURL) + } } func test_retrieveImageData_deliversFoundDataWhenThereIsAStoredImageDataMatchingURL() throws { - let sut = try makeSUT() - let storedData = anyData() - let matchingURL = URL(string: "http://a-url.com")! - - insert(storedData, for: matchingURL, into: sut) - - expect(sut, toCompleteRetrievalWith: found(storedData), for: matchingURL) + try makeSUT { sut in + let storedData = anyData() + let matchingURL = URL(string: "http://a-url.com")! + + insert(storedData, for: matchingURL, into: sut) + + expect(sut, toCompleteRetrievalWith: found(storedData), for: matchingURL) + } } func test_retrieveImageData_deliversLastInsertedValue() throws { - let sut = try makeSUT() - let firstStoredData = Data("first".utf8) - let lastStoredData = Data("last".utf8) - let url = URL(string: "http://a-url.com")! - - insert(firstStoredData, for: url, into: sut) - insert(lastStoredData, for: url, into: sut) - - expect(sut, toCompleteRetrievalWith: found(lastStoredData), for: url) + try makeSUT { sut in + let firstStoredData = Data("first".utf8) + let lastStoredData = Data("last".utf8) + let url = URL(string: "http://a-url.com")! + + insert(firstStoredData, for: url, into: sut) + insert(lastStoredData, for: url, into: sut) + + expect(sut, toCompleteRetrievalWith: found(lastStoredData), for: url) + } } // - MARK: Helpers - private func makeSUT(file: StaticString = #filePath, line: UInt = #line) throws -> CoreDataFeedStore { + private func makeSUT(_ test: @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) - return sut - } - - private func notFound() -> Result { - return .success(.none) - } - - private func found(_ data: Data) -> Result { - return .success(data) - } - - private func localImage(url: URL) -> LocalFeedImage { - return LocalFeedImage(id: UUID(), description: "any", location: "any", url: url) + + let exp = expectation(description: "wait for operation") + sut.perform { + test(sut) + exp.fulfill() + } + wait(for: [exp], timeout: 0.1) } - private func expect(_ sut: CoreDataFeedStore, 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) - - default: - XCTFail("Expected \(expectedResult), got \(receivedResult) instead", file: file, line: line) - } +private func notFound() -> Result { + return .success(.none) +} + +private func found(_ data: Data) -> Result { + return .success(data) +} + +private func localImage(url: URL) -> LocalFeedImage { + return LocalFeedImage(id: UUID(), description: "any", location: "any", url: url) +} + +private func expect(_ sut: CoreDataFeedStore, 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) + + default: + XCTFail("Expected \(expectedResult), got \(receivedResult) instead", file: file, line: line) } - - private func insert(_ data: Data, for url: URL, into sut: CoreDataFeedStore, file: StaticString = #filePath, line: UInt = #line) { - do { - let image = localImage(url: url) - try sut.insert([image], timestamp: Date()) - try sut.insert(data, for: url) - } catch { - XCTFail("Failed to insert \(data) with error \(error)", file: file, line: line) - } +} + +private func insert(_ data: Data, for url: URL, into sut: CoreDataFeedStore, file: StaticString = #filePath, line: UInt = #line) { + do { + let image = localImage(url: url) + try sut.insert([image], timestamp: Date()) + try sut.insert(data, for: url) + } catch { + XCTFail("Failed to insert \(data) with error \(error)", file: file, line: line) } - } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift index f048a963..643b6933 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift @@ -8,78 +8,84 @@ import EssentialFeed class CoreDataFeedStoreTests: XCTestCase, FeedStoreSpecs { func test_retrieve_deliversEmptyOnEmptyCache() throws { - let sut = try makeSUT() - - assertThatRetrieveDeliversEmptyOnEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatRetrieveDeliversEmptyOnEmptyCache(on: sut) + } } func test_retrieve_hasNoSideEffectsOnEmptyCache() throws { - let sut = try makeSUT() - - assertThatRetrieveHasNoSideEffectsOnEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatRetrieveHasNoSideEffectsOnEmptyCache(on: sut) + } } func test_retrieve_deliversFoundValuesOnNonEmptyCache() throws { - let sut = try makeSUT() - - assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on: sut) + } } func test_retrieve_hasNoSideEffectsOnNonEmptyCache() throws { - let sut = try makeSUT() - - assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) + } } func test_insert_deliversNoErrorOnEmptyCache() throws { - let sut = try makeSUT() - - assertThatInsertDeliversNoErrorOnEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatInsertDeliversNoErrorOnEmptyCache(on: sut) + } } func test_insert_deliversNoErrorOnNonEmptyCache() throws { - let sut = try makeSUT() - - assertThatInsertDeliversNoErrorOnNonEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatInsertDeliversNoErrorOnNonEmptyCache(on: sut) + } } func test_insert_overridesPreviouslyInsertedCacheValues() throws { - let sut = try makeSUT() - - assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) + try makeSUT { sut in + self.assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) + } } func test_delete_deliversNoErrorOnEmptyCache() throws { - let sut = try makeSUT() - - assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) + } } func test_delete_hasNoSideEffectsOnEmptyCache() throws { - let sut = try makeSUT() - - assertThatDeleteHasNoSideEffectsOnEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatDeleteHasNoSideEffectsOnEmptyCache(on: sut) + } } func test_delete_deliversNoErrorOnNonEmptyCache() throws { - let sut = try makeSUT() - - assertThatDeleteDeliversNoErrorOnNonEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatDeleteDeliversNoErrorOnNonEmptyCache(on: sut) + } } func test_delete_emptiesPreviouslyInsertedCache() throws { - let sut = try makeSUT() - - assertThatDeleteEmptiesPreviouslyInsertedCache(on: sut) + try makeSUT { sut in + self.assertThatDeleteEmptiesPreviouslyInsertedCache(on: sut) + } } // - MARK: Helpers - private func makeSUT(file: StaticString = #filePath, line: UInt = #line) throws -> FeedStore { + private func makeSUT(_ test: @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) - return sut + + let exp = expectation(description: "wait for operation") + sut.perform { + test(sut) + exp.fulfill() + } + wait(for: [exp], timeout: 0.1) } } From aa805fe9762e10c0d96b76cdc697bccb1fd5e545 Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Fri, 22 Mar 2024 15:32:08 +0200 Subject: [PATCH 3/7] Run cache integration tests with a Core Data main queue context --- .../EssentialFeedCacheIntegrationTests.xcscheme | 6 ++++++ .../Infrastructure/CoreData/CoreDataFeedStore.swift | 9 +++++++-- .../EssentialFeedCacheIntegrationTests.swift | 4 ++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme index 79fbba6a..1a50d311 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme +++ b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme @@ -46,6 +46,12 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> + + + + LocalFeedLoader { let storeURL = testSpecificStoreURL() - let store = try CoreDataFeedStore(storeURL: storeURL) + let store = try CoreDataFeedStore(storeURL: storeURL, contextQueue: .main) let sut = LocalFeedLoader(store: store, currentDate: { currentDate }) trackForMemoryLeaks(store, file: file, line: line) trackForMemoryLeaks(sut, file: file, line: line) @@ -116,7 +116,7 @@ class EssentialFeedCacheIntegrationTests: XCTestCase { private func makeImageLoader(file: StaticString = #filePath, line: UInt = #line) throws -> LocalFeedImageDataLoader { let storeURL = testSpecificStoreURL() - let store = try CoreDataFeedStore(storeURL: storeURL) + let store = try CoreDataFeedStore(storeURL: storeURL, contextQueue: .main) let sut = LocalFeedImageDataLoader(store: store) trackForMemoryLeaks(store, file: file, line: line) trackForMemoryLeaks(sut, file: file, line: line) From eafe117dc85960b313b81073ce3a5f5f9d9b9474 Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Fri, 22 Mar 2024 15:33:35 +0200 Subject: [PATCH 4/7] Enable CoreData concurrency debug in CI_macOS scheme --- .../xcshareddata/xcschemes/CI_macOS.xcscheme | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_macOS.xcscheme b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_macOS.xcscheme index 5453256e..52b5a384 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_macOS.xcscheme +++ b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_macOS.xcscheme @@ -106,6 +106,12 @@ ReferencedContainer = "container:EssentialFeed.xcodeproj"> + + + + Date: Mon, 25 Mar 2024 12:08:29 +0200 Subject: [PATCH 5/7] Wrap store operations with Core Data store queue context scheduler --- .../EssentialApp/CombineHelpers.swift | 37 +++++++++++ EssentialApp/EssentialApp/SceneDelegate.swift | 19 ++++-- .../FeedAcceptanceTests.swift | 64 +++++++++++++------ .../CoreData/CoreDataFeedStore.swift | 4 ++ 4 files changed, 97 insertions(+), 27 deletions(-) diff --git a/EssentialApp/EssentialApp/CombineHelpers.swift b/EssentialApp/EssentialApp/CombineHelpers.swift index 23c205d4..0f11130b 100644 --- a/EssentialApp/EssentialApp/CombineHelpers.swift +++ b/EssentialApp/EssentialApp/CombineHelpers.swift @@ -213,6 +213,43 @@ extension AnyDispatchQueueScheduler { static var immediateOnMainThread: Self { DispatchQueue.immediateWhenOnMainThreadScheduler.eraseToAnyScheduler() } + + static func scheduler(for store: CoreDataFeedStore) -> AnyDispatchQueueScheduler { + CoreDataFeedStoreScheduler(store: store).eraseToAnyScheduler() + } + + private struct CoreDataFeedStoreScheduler: Scheduler { + let store: CoreDataFeedStore + + var now: SchedulerTimeType { .init(.now()) } + + var minimumTolerance: SchedulerTimeType.Stride { .zero } + + func schedule(after date: DispatchQueue.SchedulerTimeType, interval: DispatchQueue.SchedulerTimeType.Stride, tolerance: DispatchQueue.SchedulerTimeType.Stride, options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) -> any Cancellable { + if store.contextQueue == .main, Thread.isMainThread { + action() + } else { + store.perform(action) + } + return AnyCancellable {} + } + + func schedule(after date: DispatchQueue.SchedulerTimeType, tolerance: DispatchQueue.SchedulerTimeType.Stride, options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) { + if store.contextQueue == .main, Thread.isMainThread { + action() + } else { + store.perform(action) + } + } + + func schedule(options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) { + if store.contextQueue == .main, Thread.isMainThread { + action() + } else { + store.perform(action) + } + } + } } extension Scheduler { diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index 32cefcbe..c6c8f296 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -11,11 +11,17 @@ import EssentialFeed class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - private lazy var scheduler: AnyDispatchQueueScheduler = DispatchQueue( - label: "com.essentialdeveloper.infra.queue", - qos: .userInitiated, - attributes: .concurrent - ).eraseToAnyScheduler() + private lazy var scheduler: AnyDispatchQueueScheduler = { + if let store = store as? CoreDataFeedStore { + return .scheduler(for: store) + } + + return DispatchQueue( + label: "com.essentialdeveloper.infra.queue", + qos: .userInitiated, + attributes: .concurrent + ).eraseToAnyScheduler() + }() private lazy var httpClient: HTTPClient = { URLSessionHTTPClient(session: URLSession(configuration: .ephemeral)) @@ -48,11 +54,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { imageLoader: makeLocalImageLoaderWithRemoteFallback, selection: showComments)) - convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore, scheduler: AnyDispatchQueueScheduler) { + convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore) { self.init() self.httpClient = httpClient self.store = store - self.scheduler = scheduler } func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index 43958298..443ea3ae 100644 --- a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -9,8 +9,8 @@ import EssentialFeediOS class FeedAcceptanceTests: XCTestCase { - func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() { - let feed = launch(httpClient: .online(response), store: .empty) + func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() throws { + let feed = try launch(httpClient: .online(response), store: .empty) XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 2) XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0()) @@ -34,8 +34,8 @@ class FeedAcceptanceTests: XCTestCase { XCTAssertFalse(feed.canLoadMoreFeed) } - func test_onLaunch_displaysCachedRemoteFeedWhenCustomerHasNoConnectivity() { - let sharedStore = InMemoryFeedStore.empty + func test_onLaunch_displaysCachedRemoteFeedWhenCustomerHasNoConnectivity() throws { + let sharedStore = try CoreDataFeedStore.empty let onlineFeed = launch(httpClient: .online(response), store: sharedStore) onlineFeed.simulateFeedImageViewVisible(at: 0) @@ -51,30 +51,30 @@ class FeedAcceptanceTests: XCTestCase { XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 2), makeImageData2()) } - func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() { - let feed = launch(httpClient: .offline, store: .empty) + func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() throws { + let feed = try launch(httpClient: .offline, store: .empty) XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 0) } - func test_onEnteringBackground_deletesExpiredFeedCache() { - let store = InMemoryFeedStore.withExpiredFeedCache + func test_onEnteringBackground_deletesExpiredFeedCache() throws { + let store = try CoreDataFeedStore.withExpiredFeedCache enterBackground(with: store) - XCTAssertNil(store.feedCache, "Expected to delete expired cache") + XCTAssertNil(try store.retrieve(), "Expected to delete expired cache") } - func test_onEnteringBackground_keepsNonExpiredFeedCache() { - let store = InMemoryFeedStore.withNonExpiredFeedCache + func test_onEnteringBackground_keepsNonExpiredFeedCache() throws { + let store = try CoreDataFeedStore.withNonExpiredFeedCache enterBackground(with: store) - XCTAssertNotNil(store.feedCache, "Expected to keep non-expired cache") + XCTAssertNotNil(try store.retrieve(), "Expected to keep non-expired cache") } - func test_onFeedImageSelection_displaysComments() { - let comments = showCommentsForFirstImage() + func test_onFeedImageSelection_displaysComments() throws { + let comments = try showCommentsForFirstImage() XCTAssertEqual(comments.numberOfRenderedComments(), 1) XCTAssertEqual(comments.commentMessage(at: 0), makeCommentMessage()) @@ -84,9 +84,9 @@ class FeedAcceptanceTests: XCTestCase { private func launch( httpClient: HTTPClientStub = .offline, - store: InMemoryFeedStore = .empty + store: CoreDataFeedStore ) -> ListViewController { - let sut = SceneDelegate(httpClient: httpClient, store: store, scheduler: .immediateOnMainThread) + let sut = SceneDelegate(httpClient: httpClient, store: store) sut.window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 1)) sut.configureWindow() @@ -96,13 +96,13 @@ class FeedAcceptanceTests: XCTestCase { return vc } - private func enterBackground(with store: InMemoryFeedStore) { - let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store, scheduler: .immediateOnMainThread) + private func enterBackground(with store: CoreDataFeedStore) { + let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store) sut.sceneWillResignActive(UIApplication.shared.connectedScenes.first!) } - private func showCommentsForFirstImage() -> ListViewController { - let feed = launch(httpClient: .online(response), store: .empty) + private func showCommentsForFirstImage() throws -> ListViewController { + let feed = try launch(httpClient: .online(response), store: .empty) feed.simulateTapOnFeedImage(at: 0) RunLoop.current.run(until: Date()) @@ -180,3 +180,27 @@ class FeedAcceptanceTests: XCTestCase { } } + +extension CoreDataFeedStore { + static var empty: CoreDataFeedStore { + get throws { + try CoreDataFeedStore(storeURL: URL(fileURLWithPath: "/dev/null"), contextQueue: .main) + } + } + + static var withExpiredFeedCache: CoreDataFeedStore { + get throws { + let store = try CoreDataFeedStore.empty + try store.insert([], timestamp: .distantPast) + return store + } + } + + static var withNonExpiredFeedCache: CoreDataFeedStore { + get throws { + let store = try CoreDataFeedStore.empty + try store.insert([], timestamp: Date()) + return store + } + } +} diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift index 5e73693c..52650607 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift @@ -21,6 +21,10 @@ public final class CoreDataFeedStore { case background } + public var contextQueue: ContextQueue { + context == container.viewContext ? .main : .background + } + public init(storeURL: URL, contextQueue: ContextQueue = .background) throws { guard let model = CoreDataFeedStore.model else { throw StoreError.modelNotFound From 6030693d5f559e77befd5942eeffe21b63414e0f Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Fri, 22 Mar 2024 15:48:49 +0200 Subject: [PATCH 6/7] Remove performSync method --- ...CoreDataFeedStore+FeedImageDataStore.swift | 16 +++--------- .../CoreDataFeedStore+FeedStore.swift | 26 +++++-------------- .../CoreData/CoreDataFeedStore.swift | 9 +------ 3 files changed, 12 insertions(+), 39 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedImageDataStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedImageDataStore.swift index d95be9f8..9e687eca 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedImageDataStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedImageDataStore.swift @@ -7,21 +7,13 @@ import Foundation extension CoreDataFeedStore: FeedImageDataStore { public func insert(_ data: Data, for url: URL) throws { - try performSync { context in - Result { - try ManagedFeedImage.first(with: url, in: context) - .map { $0.data = data } - .map(context.save) - } - } + try ManagedFeedImage.first(with: url, in: context) + .map { $0.data = data } + .map(context.save) } public func retrieve(dataForURL url: URL) throws -> Data? { - try performSync { context in - Result { - try ManagedFeedImage.data(with: url, in: context) - } - } + try ManagedFeedImage.data(with: url, in: context) } } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift index 88b272c7..46db2f89 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift @@ -7,32 +7,20 @@ import CoreData extension CoreDataFeedStore: FeedStore { public func retrieve() throws -> CachedFeed? { - try performSync { context in - Result { - try ManagedCache.find(in: context).map { - CachedFeed(feed: $0.localFeed, timestamp: $0.timestamp) - } - } + try ManagedCache.find(in: context).map { + CachedFeed(feed: $0.localFeed, timestamp: $0.timestamp) } } public func insert(_ feed: [LocalFeedImage], timestamp: Date) throws { - try performSync { context in - Result { - let managedCache = try ManagedCache.newUniqueInstance(in: context) - managedCache.timestamp = timestamp - managedCache.feed = ManagedFeedImage.images(from: feed, in: context) - try context.save() - } - } + let managedCache = try ManagedCache.newUniqueInstance(in: context) + managedCache.timestamp = timestamp + managedCache.feed = ManagedFeedImage.images(from: feed, in: context) + try context.save() } public func deleteCachedFeed() throws { - try performSync { context in - Result { - try ManagedCache.deleteCache(in: context) - } - } + try ManagedCache.deleteCache(in: context) } } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift index 52650607..8b935c4b 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift @@ -9,7 +9,7 @@ public final class CoreDataFeedStore { private static let model = NSManagedObjectModel.with(name: modelName, in: Bundle(for: CoreDataFeedStore.self)) private let container: NSPersistentContainer - private let context: NSManagedObjectContext + let context: NSManagedObjectContext enum StoreError: Error { case modelNotFound @@ -38,13 +38,6 @@ public final class CoreDataFeedStore { } } - func performSync(_ action: (NSManagedObjectContext) -> Result) throws -> R { - let context = self.context - var result: Result! - context.performAndWait { result = action(context) } - return try result.get() - } - public func perform(_ action: @escaping () -> Void) { context.perform(action) } From 0c13745be2cce180b89b977967c62631e04b163f Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Fri, 22 Mar 2024 16:18:14 +0200 Subject: [PATCH 7/7] Enable CoreData concurrency debug in CI_iOS scheme --- .../xcshareddata/xcschemes/CI_iOS.xcscheme | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/CI_iOS.xcscheme b/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/CI_iOS.xcscheme index b5f14ef4..37505d43 100644 --- a/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/CI_iOS.xcscheme +++ b/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/CI_iOS.xcscheme @@ -139,6 +139,12 @@ ReferencedContainer = "container:../EssentialFeed/EssentialFeed.xcodeproj"> + + + +