diff --git a/EssentialApp/EssentialApp/CombineHelpers.swift b/EssentialApp/EssentialApp/CombineHelpers.swift index 1198596b..61fd2243 100644 --- a/EssentialApp/EssentialApp/CombineHelpers.swift +++ b/EssentialApp/EssentialApp/CombineHelpers.swift @@ -6,32 +6,6 @@ import Foundation import Combine import EssentialFeed -public extension Paginated { - init(items: [Item], loadMorePublisher: (() -> AnyPublisher)?) { - self.init(items: items, loadMore: loadMorePublisher.map { publisher in - return { completion in - publisher().subscribe(Subscribers.Sink(receiveCompletion: { result in - if case let .failure(error) = result { - completion(.failure(error)) - } - }, receiveValue: { result in - completion(.success(result)) - })) - } - }) - } - - var loadMorePublisher: (() -> AnyPublisher)? { - guard let loadMore = loadMore else { return nil } - - return { - Deferred { - Future(loadMore) - }.eraseToAnyPublisher() - } - } -} - @MainActor public extension HTTPClient { typealias Publisher = AnyPublisher<(Data, HTTPURLResponse), Error> diff --git a/EssentialApp/EssentialApp/FeedUIComposer.swift b/EssentialApp/EssentialApp/FeedUIComposer.swift index 0c6d4e5a..8cb9fc60 100644 --- a/EssentialApp/EssentialApp/FeedUIComposer.swift +++ b/EssentialApp/EssentialApp/FeedUIComposer.swift @@ -3,7 +3,6 @@ // import UIKit -import Combine import EssentialFeed import EssentialFeediOS @@ -11,10 +10,10 @@ import EssentialFeediOS public final class FeedUIComposer { private init() {} - private typealias FeedPresentationAdapter = LoadResourcePresentationAdapter, FeedViewAdapter> + private typealias FeedPresentationAdapter = AsyncLoadResourcePresentationAdapter, FeedViewAdapter> public static func feedComposedWith( - feedLoader: @MainActor @escaping () -> AnyPublisher, Error>, + feedLoader: @MainActor @escaping () async throws -> Paginated, imageLoader: @MainActor @escaping (URL) async throws -> Data, selection: @MainActor @escaping (FeedImage) -> Void = { _ in } ) -> ListViewController { diff --git a/EssentialApp/EssentialApp/FeedViewAdapter.swift b/EssentialApp/EssentialApp/FeedViewAdapter.swift index bf544d98..537bd471 100644 --- a/EssentialApp/EssentialApp/FeedViewAdapter.swift +++ b/EssentialApp/EssentialApp/FeedViewAdapter.swift @@ -14,7 +14,7 @@ final class FeedViewAdapter: ResourceView { private let currentFeed: [FeedImage: CellController] private typealias ImageDataPresentationAdapter = AsyncLoadResourcePresentationAdapter> - private typealias LoadMorePresentationAdapter = LoadResourcePresentationAdapter, FeedViewAdapter> + private typealias LoadMorePresentationAdapter = AsyncLoadResourcePresentationAdapter, FeedViewAdapter> init(currentFeed: [FeedImage: CellController] = [:], controller: ListViewController, imageLoader: @escaping (URL) async throws -> Data, selection: @escaping (FeedImage) -> Void) { self.currentFeed = currentFeed @@ -54,12 +54,12 @@ final class FeedViewAdapter: ResourceView { return controller } - guard let loadMorePublisher = viewModel.loadMorePublisher else { + guard let loadMoreAsync = viewModel.loadMore else { controller.display(feed) return } - let loadMoreAdapter = LoadMorePresentationAdapter(loader: loadMorePublisher) + let loadMoreAdapter = LoadMorePresentationAdapter(loader: loadMoreAsync) let loadMore = LoadMoreCellController(callback: loadMoreAdapter.loadResource) loadMoreAdapter.presenter = LoadResourcePresenter( diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index 1d44a94e..dbe14da2 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -49,7 +49,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private lazy var navigationController = UINavigationController( rootViewController: FeedUIComposer.feedComposedWith( - feedLoader: makeRemoteFeedLoaderWithLocalFallback, + feedLoader: loadRemoteFeedWithLocalFallback, imageLoader: loadLocalImageWithRemoteFallback, selection: showComments)) @@ -96,35 +96,50 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } - private func makeRemoteFeedLoaderWithLocalFallback() -> AnyPublisher, Error> { - makeRemoteFeedLoader() - .receive(on: scheduler) - .caching(to: localFeedLoader) - .fallback(to: localFeedLoader.loadPublisher) - .map(makeFirstPage) - .eraseToAnyPublisher() + private func loadRemoteFeedWithLocalFallback() async throws -> Paginated { + do { + let feed = try await loadAndCacheRemoteFeed() + return makeFirstPage(items: feed) + } catch { + let feed = try await loadLocalFeed() + return makeFirstPage(items: feed) + } } - private func makeRemoteLoadMoreLoader(last: FeedImage?) -> AnyPublisher, Error> { - localFeedLoader.loadPublisher() - .zip(makeRemoteFeedLoader(after: last)) - .map { (cachedItems, newItems) in - (cachedItems + newItems, newItems.last) - } - .map(makePage) - .receive(on: scheduler) - .caching(to: localFeedLoader) - .subscribe(on: scheduler) - .eraseToAnyPublisher() + private func loadAndCacheRemoteFeed() async throws -> [FeedImage] { + let feed = try await loadRemoteFeed() + await store.schedule { [store] in + let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init) + try? localFeedLoader.save(feed) + } + return feed + } + + private func loadLocalFeed() async throws -> [FeedImage] { + try await store.schedule { [store] in + let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init) + return try localFeedLoader.load() + } } - private func makeRemoteFeedLoader(after: FeedImage? = nil) -> AnyPublisher<[FeedImage], Error> { + private func loadRemoteFeed(after: FeedImage? = nil) async throws -> [FeedImage] { let url = FeedEndpoint.get(after: after).url(baseURL: baseURL) + let (data, response) = try await httpClient.get(from: url) + return try FeedItemsMapper.map(data, from: response) + } + + private func loadMoreRemoteFeed(last: FeedImage?) async throws -> Paginated { + async let cachedItems = try await loadLocalFeed() + async let newItems = try await loadRemoteFeed(after: last) + + let items = try await cachedItems + newItems + + await store.schedule { [store] in + let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init) + try? localFeedLoader.save(items) + } - return httpClient - .getPublisher(url: url) - .tryMap(FeedItemsMapper.map) - .eraseToAnyPublisher() + return try await makePage(items: items, last: newItems.last) } private func makeFirstPage(items: [FeedImage]) -> Paginated { @@ -132,8 +147,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } private func makePage(items: [FeedImage], last: FeedImage?) -> Paginated { - Paginated(items: items, loadMorePublisher: last.map { last in - { self.makeRemoteLoadMoreLoader(last: last) } + Paginated(items: items, loadMore: last.map { last in + { @MainActor @Sendable in try await self.loadMoreRemoteFeed(last: last) } }) } diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index 9490c6d6..46f88ec1 100644 --- a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -11,14 +11,17 @@ import EssentialFeediOS class FeedAcceptanceTests: XCTestCase { func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() throws { - let feed = try launch(httpClient: .online(response), store: .empty) + let store = try CoreDataFeedStore.empty + let feed = try launch(httpClient: .online(response), store: store) XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 2) XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0()) XCTAssertEqual(feed.renderedFeedImageData(at: 1), makeImageData1()) XCTAssertTrue(feed.canLoadMoreFeed) - feed.simulateLoadMoreFeedAction() + try store.withWaitingChanges { + feed.simulateLoadMoreFeedAction() + } XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 3) XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0()) @@ -26,7 +29,9 @@ class FeedAcceptanceTests: XCTestCase { XCTAssertEqual(feed.renderedFeedImageData(at: 2), makeImageData2()) XCTAssertTrue(feed.canLoadMoreFeed) - feed.simulateLoadMoreFeedAction() + try store.withWaitingChanges { + feed.simulateLoadMoreFeedAction() + } XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 3) XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0()) @@ -41,7 +46,9 @@ class FeedAcceptanceTests: XCTestCase { let onlineFeed = try launch(httpClient: .online(response), store: sharedStore) onlineFeed.simulateFeedImageViewVisible(at: 0) onlineFeed.simulateFeedImageViewVisible(at: 1) - onlineFeed.simulateLoadMoreFeedAction() + try sharedStore.withWaitingChanges { + onlineFeed.simulateLoadMoreFeedAction() + } onlineFeed.simulateFeedImageViewVisible(at: 2) let offlineFeed = try launch(httpClient: .offline, store: sharedStore) @@ -186,6 +193,25 @@ class FeedAcceptanceTests: XCTestCase { @MainActor extension CoreDataFeedStore { + private struct Timeout: Error {} + + func withWaitingChanges(_ action: () -> Void, timeout: TimeInterval = 1) throws { + let state = try retrieve()?.timestamp + action() + + let maxDate = Date() + timeout + + while Date() <= maxDate { + if try retrieve()?.timestamp != state { + return + } + + RunLoop.current.run(until: Date()) + } + + throw Timeout() + } + static var empty: CoreDataFeedStore { get throws { try CoreDataFeedStore(storeURL: URL(fileURLWithPath: "/dev/null"), contextQueue: .main) diff --git a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift index 0d0edb81..cb4f0389 100644 --- a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift @@ -11,7 +11,7 @@ import EssentialFeediOS @MainActor class FeedUIIntegrationTests: XCTestCase { - func test_feedView_hasTitle() { + func test_feedView_hasTitle() async { let (sut, _) = makeSUT() sut.simulateAppearance() @@ -19,14 +19,14 @@ class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(sut.title, feedTitle) } - func test_imageSelection_notifiesHandler() { + func test_imageSelection_notifiesHandler() async { let image0 = makeImage() let image1 = makeImage() var selectedImages = [FeedImage]() let (sut, loader) = makeSUT(selection: { selectedImages.append($0) }) sut.simulateAppearance() - loader.completeFeedLoading(with: [image0, image1], at: 0) + await loader.completeFeedLoading(with: [image0, image1], at: 0) sut.simulateTapOnFeedImage(at: 0) XCTAssertEqual(selectedImages, [image0]) @@ -35,7 +35,7 @@ class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(selectedImages, [image0, image1]) } - func test_loadFeedActions_requestFeedFromLoader() { + func test_loadFeedActions_requestFeedFromLoader() async { let (sut, loader) = makeSUT() XCTAssertEqual(loader.loadFeedCallCount, 0, "Expected no loading requests before view appears") @@ -45,16 +45,16 @@ class FeedUIIntegrationTests: XCTestCase { sut.simulateUserInitiatedReload() XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected no request until previous completes") - loader.completeFeedLoading(at: 0) + await loader.completeFeedLoading(at: 0) sut.simulateUserInitiatedReload() XCTAssertEqual(loader.loadFeedCallCount, 2, "Expected another loading request once user initiates a reload") - loader.completeFeedLoading(at: 1) + await loader.completeFeedLoading(at: 1) sut.simulateUserInitiatedReload() XCTAssertEqual(loader.loadFeedCallCount, 3, "Expected yet another loading request once user initiates another reload") } - func test_loadFeedActions_runsAutomaticallyOnlyOnFirstAppearance() { + func test_loadFeedActions_runsAutomaticallyOnlyOnFirstAppearance() async { let (sut, loader) = makeSUT() XCTAssertEqual(loader.loadFeedCallCount, 0, "Expected no loading requests before view appears") @@ -65,23 +65,23 @@ class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected no loading request the second time view appears") } - func test_loadingFeedIndicator_isVisibleWhileLoadingFeed() { + func test_loadingFeedIndicator_isVisibleWhileLoadingFeed() async { let (sut, loader) = makeSUT() sut.simulateAppearance() XCTAssertTrue(sut.isShowingLoadingIndicator, "Expected loading indicator once view appears") - loader.completeFeedLoading(at: 0) + await loader.completeFeedLoading(at: 0) XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once loading completes successfully") sut.simulateUserInitiatedReload() XCTAssertTrue(sut.isShowingLoadingIndicator, "Expected loading indicator once user initiates a reload") - loader.completeFeedLoadingWithError(at: 1) + await loader.completeFeedLoadingWithError(at: 1) XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once user initiated loading completes with error") } - func test_loadFeedCompletion_rendersSuccessfullyLoadedFeed() { + func test_loadFeedCompletion_rendersSuccessfullyLoadedFeed() async { let image0 = makeImage(description: "a description", location: "a location") let image1 = makeImage(description: nil, location: "another location") let image2 = makeImage(description: "another description", location: nil) @@ -91,73 +91,73 @@ class FeedUIIntegrationTests: XCTestCase { sut.simulateAppearance() assertThat(sut, isRendering: []) - loader.completeFeedLoading(with: [image0, image1], at: 0) + await loader.completeFeedLoading(with: [image0, image1], at: 0) assertThat(sut, isRendering: [image0, image1]) sut.simulateLoadMoreFeedAction() - loader.completeLoadMore(with: [image0, image1, image2, image3], at: 0) + await loader.completeLoadMore(with: [image0, image1, image2, image3], at: 0) assertThat(sut, isRendering: [image0, image1, image2, image3]) sut.simulateUserInitiatedReload() - loader.completeFeedLoading(with: [image0, image1], at: 1) + await loader.completeFeedLoading(with: [image0, image1], at: 1) assertThat(sut, isRendering: [image0, image1]) } - func test_loadFeedCompletion_rendersSuccessfullyLoadedEmptyFeedAfterNonEmptyFeed() { + func test_loadFeedCompletion_rendersSuccessfullyLoadedEmptyFeedAfterNonEmptyFeed() async { let image0 = makeImage() let image1 = makeImage() let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [image0], at: 0) + await loader.completeFeedLoading(with: [image0], at: 0) assertThat(sut, isRendering: [image0]) sut.simulateLoadMoreFeedAction() - loader.completeLoadMore(with: [image0, image1], at: 0) + await loader.completeLoadMore(with: [image0, image1], at: 0) assertThat(sut, isRendering: [image0, image1]) sut.simulateUserInitiatedReload() - loader.completeFeedLoading(with: [], at: 1) + await loader.completeFeedLoading(with: [], at: 1) assertThat(sut, isRendering: []) } - func test_loadFeedCompletion_doesNotAlterCurrentRenderingStateOnError() { + func test_loadFeedCompletion_doesNotAlterCurrentRenderingStateOnError() async { let image0 = makeImage() let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [image0], at: 0) + await loader.completeFeedLoading(with: [image0], at: 0) assertThat(sut, isRendering: [image0]) sut.simulateUserInitiatedReload() - loader.completeFeedLoadingWithError(at: 1) + await loader.completeFeedLoadingWithError(at: 1) assertThat(sut, isRendering: [image0]) sut.simulateLoadMoreFeedAction() - loader.completeLoadMoreWithError(at: 0) + await loader.completeLoadMoreWithError(at: 0) assertThat(sut, isRendering: [image0]) } - func test_loadFeedCompletion_rendersErrorMessageOnErrorUntilNextReload() { + func test_loadFeedCompletion_rendersErrorMessageOnErrorUntilNextReload() async { let (sut, loader) = makeSUT() sut.simulateAppearance() XCTAssertEqual(sut.errorMessage, nil) - loader.completeFeedLoadingWithError(at: 0) + await loader.completeFeedLoadingWithError(at: 0) XCTAssertEqual(sut.errorMessage, loadError) sut.simulateUserInitiatedReload() XCTAssertEqual(sut.errorMessage, nil) } - func test_tapOnErrorView_hidesErrorMessage() { + func test_tapOnErrorView_hidesErrorMessage() async { let (sut, loader) = makeSUT() sut.simulateAppearance() XCTAssertEqual(sut.errorMessage, nil) - loader.completeFeedLoadingWithError(at: 0) + await loader.completeFeedLoadingWithError(at: 0) XCTAssertEqual(sut.errorMessage, loadError) sut.simulateErrorViewTap() @@ -166,10 +166,10 @@ class FeedUIIntegrationTests: XCTestCase { // MARK: - Load More Tests - func test_loadMoreActions_requestMoreFromLoader() { + func test_loadMoreActions_requestMoreFromLoader() async { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [makeImage()]) + await loader.completeFeedLoading(with: [makeImage()]) XCTAssertEqual(loader.loadMoreCallCount, 0, "Expected no requests before until load more action") @@ -179,60 +179,60 @@ class FeedUIIntegrationTests: XCTestCase { sut.simulateLoadMoreFeedAction() XCTAssertEqual(loader.loadMoreCallCount, 1, "Expected no request while loading more") - loader.completeLoadMore(lastPage: false, at: 0) + await loader.completeLoadMore(lastPage: false, at: 0) sut.simulateLoadMoreFeedAction() XCTAssertEqual(loader.loadMoreCallCount, 2, "Expected request after load more completed with more pages") - loader.completeLoadMoreWithError(at: 1) + await loader.completeLoadMoreWithError(at: 1) sut.simulateLoadMoreFeedAction() XCTAssertEqual(loader.loadMoreCallCount, 3, "Expected request after load more failure") - loader.completeLoadMore(lastPage: true, at: 2) + await loader.completeLoadMore(lastPage: true, at: 2) sut.simulateLoadMoreFeedAction() XCTAssertEqual(loader.loadMoreCallCount, 3, "Expected no request after loading all pages") } - func test_loadingMoreIndicator_isVisibleWhileLoadingMore() { + func test_loadingMoreIndicator_isVisibleWhileLoadingMore() async { let (sut, loader) = makeSUT() sut.simulateAppearance() XCTAssertFalse(sut.isShowingLoadMoreFeedIndicator, "Expected no loading indicator once view appears") - loader.completeFeedLoading(with: [makeImage()], at: 0) + await loader.completeFeedLoading(with: [makeImage()], at: 0) XCTAssertFalse(sut.isShowingLoadMoreFeedIndicator, "Expected no loading indicator once loading completes successfully") sut.simulateLoadMoreFeedAction() XCTAssertTrue(sut.isShowingLoadMoreFeedIndicator, "Expected loading indicator on load more action") - loader.completeLoadMore(with: [makeImage()], at: 0) + await loader.completeLoadMore(with: [makeImage()], at: 0) XCTAssertFalse(sut.isShowingLoadMoreFeedIndicator, "Expected no loading indicator once user initiated loading completes successfully") sut.simulateLoadMoreFeedAction() XCTAssertTrue(sut.isShowingLoadMoreFeedIndicator, "Expected loading indicator on second load more action") - loader.completeLoadMoreWithError(at: 1) + await loader.completeLoadMoreWithError(at: 1) XCTAssertFalse(sut.isShowingLoadMoreFeedIndicator, "Expected no loading indicator once user initiated loading completes with error") } - func test_loadMoreCompletion_rendersErrorMessageOnError() { + func test_loadMoreCompletion_rendersErrorMessageOnError() async { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading() + await loader.completeFeedLoading() sut.simulateLoadMoreFeedAction() XCTAssertEqual(sut.loadMoreFeedErrorMessage, nil) - loader.completeLoadMoreWithError() + await loader.completeLoadMoreWithError() XCTAssertEqual(sut.loadMoreFeedErrorMessage, loadError) sut.simulateLoadMoreFeedAction() XCTAssertEqual(sut.loadMoreFeedErrorMessage, nil) } - func test_tapOnLoadMoreErrorView_loadsMore() { + func test_tapOnLoadMoreErrorView_loadsMore() async { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading() + await loader.completeFeedLoading() sut.simulateLoadMoreFeedAction() XCTAssertEqual(loader.loadMoreCallCount, 1) @@ -240,14 +240,14 @@ class FeedUIIntegrationTests: XCTestCase { sut.simulateTapOnLoadMoreFeedError() XCTAssertEqual(loader.loadMoreCallCount, 1) - loader.completeLoadMoreWithError() + await loader.completeLoadMoreWithError() sut.simulateTapOnLoadMoreFeedError() XCTAssertEqual(loader.loadMoreCallCount, 2) } // MARK: - Image View Tests - func test_feedImageView_loadsImageURLWhenVisible() { + func test_feedImageView_loadsImageURLWhenVisible() async { let image0 = makeImage(url: URL(string: "http://url-0.com")!) let image1 = makeImage(url: URL(string: "http://url-1.com")!) let (sut, loader) = makeSUT() @@ -255,7 +255,7 @@ class FeedUIIntegrationTests: XCTestCase { sut.simulateAppearance() XCTAssertEqual(loader.loadedImageURLs, [], "Expected no image URL requests until views become visible") - loader.completeFeedLoading(with: [image0, image1]) + await loader.completeFeedLoading(with: [image0, image1]) sut.simulateFeedImageViewVisible(at: 0) XCTAssertEqual(loader.loadedImageURLs, [image0.url], "Expected first image URL request once first view becomes visible") @@ -269,7 +269,7 @@ class FeedUIIntegrationTests: XCTestCase { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [image0, image1]) + await loader.completeFeedLoading(with: [image0, image1]) XCTAssertEqual(loader.cancelledImageURLs, [], "Expected no cancelled image URL requests until image is not visible") sut.simulateFeedImageViewNotVisible(at: 0) @@ -283,13 +283,13 @@ class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(loader.cancelledImageURLs, [image0.url, image1.url], "Expected two cancelled image URL requests once second image is also not visible anymore") } - func test_feedImageView_reloadsImageURLWhenBecomingVisibleAgain() { + func test_feedImageView_reloadsImageURLWhenBecomingVisibleAgain() async { let image0 = makeImage(url: URL(string: "http://url-0.com")!) let image1 = makeImage(url: URL(string: "http://url-1.com")!) let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [image0, image1]) + await loader.completeFeedLoading(with: [image0, image1]) sut.simulateFeedImageBecomingVisibleAgain(at: 0) @@ -300,22 +300,22 @@ class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(loader.loadedImageURLs, [image0.url, image0.url, image1.url, image1.url], "Expected two new image URL request after second view becomes visible again") } - func test_feedImageViewLoadingIndicator_isVisibleWhileLoadingImage() { + func test_feedImageViewLoadingIndicator_isVisibleWhileLoadingImage() async { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [makeImage(), makeImage()]) + await loader.completeFeedLoading(with: [makeImage(), makeImage()]) let view0 = sut.simulateFeedImageViewVisible(at: 0) let view1 = sut.simulateFeedImageViewVisible(at: 1) XCTAssertEqual(view0?.isShowingImageLoadingIndicator, true, "Expected loading indicator for first view while loading first image") XCTAssertEqual(view1?.isShowingImageLoadingIndicator, true, "Expected loading indicator for second view while loading second image") - loader.completeImageLoading(at: 0) + await loader.completeImageLoading(at: 0) XCTAssertEqual(view0?.isShowingImageLoadingIndicator, false, "Expected no loading indicator for first view once first image loading completes successfully") XCTAssertEqual(view1?.isShowingImageLoadingIndicator, true, "Expected no loading indicator state change for second view once first image loading completes successfully") - loader.completeImageLoadingWithError(at: 1) + await loader.completeImageLoadingWithError(at: 1) XCTAssertEqual(view0?.isShowingImageLoadingIndicator, false, "Expected no loading indicator state change for first view once second image loading completes with error") XCTAssertEqual(view1?.isShowingImageLoadingIndicator, false, "Expected no loading indicator for second view once second image loading completes with error") @@ -324,11 +324,11 @@ class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(view1?.isShowingImageLoadingIndicator, true, "Expected loading indicator state change for second view on retry action") } - func test_feedImageView_rendersImageLoadedFromURL() { + func test_feedImageView_rendersImageLoadedFromURL() async { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [makeImage(), makeImage()]) + await loader.completeFeedLoading(with: [makeImage(), makeImage()]) let view0 = sut.simulateFeedImageViewVisible(at: 0) let view1 = sut.simulateFeedImageViewVisible(at: 1) @@ -336,21 +336,21 @@ class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(view1?.renderedImage, .none, "Expected no image for second view while loading second image") let imageData0 = UIImage.make(withColor: .red).pngData()! - loader.completeImageLoading(with: imageData0, at: 0) + await loader.completeImageLoading(with: imageData0, at: 0) XCTAssertEqual(view0?.renderedImage, imageData0, "Expected image for first view once first image loading completes successfully") XCTAssertEqual(view1?.renderedImage, .none, "Expected no image state change for second view once first image loading completes successfully") let imageData1 = UIImage.make(withColor: .blue).pngData()! - loader.completeImageLoading(with: imageData1, at: 1) + await loader.completeImageLoading(with: imageData1, at: 1) XCTAssertEqual(view0?.renderedImage, imageData0, "Expected no image state change for first view once second image loading completes successfully") XCTAssertEqual(view1?.renderedImage, imageData1, "Expected image for second view once second image loading completes successfully") } - func test_feedImageViewRetryButton_isVisibleOnImageURLLoadError() { + func test_feedImageViewRetryButton_isVisibleOnImageURLLoadError() async { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [makeImage(), makeImage()]) + await loader.completeFeedLoading(with: [makeImage(), makeImage()]) let view0 = sut.simulateFeedImageViewVisible(at: 0) let view1 = sut.simulateFeedImageViewVisible(at: 1) @@ -358,11 +358,11 @@ class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(view1?.isShowingRetryAction, false, "Expected no retry action for second view while loading second image") let imageData = UIImage.make(withColor: .red).pngData()! - loader.completeImageLoading(with: imageData, at: 0) + await loader.completeImageLoading(with: imageData, at: 0) XCTAssertEqual(view0?.isShowingRetryAction, false, "Expected no retry action for first view once first image loading completes successfully") XCTAssertEqual(view1?.isShowingRetryAction, false, "Expected no retry action state change for second view once first image loading completes successfully") - loader.completeImageLoadingWithError(at: 1) + await loader.completeImageLoadingWithError(at: 1) XCTAssertEqual(view0?.isShowingRetryAction, false, "Expected no retry action state change for first view once second image loading completes with error") XCTAssertEqual(view1?.isShowingRetryAction, true, "Expected retry action for second view once second image loading completes with error") @@ -371,34 +371,34 @@ class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(view1?.isShowingRetryAction, false, "Expected no retry action for second view on retry") } - func test_feedImageViewRetryButton_isVisibleOnInvalidImageData() { + func test_feedImageViewRetryButton_isVisibleOnInvalidImageData() async { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [makeImage()]) + await loader.completeFeedLoading(with: [makeImage()]) let view = sut.simulateFeedImageViewVisible(at: 0) XCTAssertEqual(view?.isShowingRetryAction, false, "Expected no retry action while loading image") let invalidImageData = Data("invalid image data".utf8) - loader.completeImageLoading(with: invalidImageData, at: 0) + await loader.completeImageLoading(with: invalidImageData, at: 0) XCTAssertEqual(view?.isShowingRetryAction, true, "Expected retry action once image loading completes with invalid image data") } - func test_feedImageViewRetryAction_retriesImageLoad() { + func test_feedImageViewRetryAction_retriesImageLoad() async { let image0 = makeImage(url: URL(string: "http://url-0.com")!) let image1 = makeImage(url: URL(string: "http://url-1.com")!) let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [image0, image1]) + await loader.completeFeedLoading(with: [image0, image1]) let view0 = sut.simulateFeedImageViewVisible(at: 0) let view1 = sut.simulateFeedImageViewVisible(at: 1) XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url], "Expected two image URL request for the two visible views") - loader.completeImageLoadingWithError(at: 0) - loader.completeImageLoadingWithError(at: 1) + await loader.completeImageLoadingWithError(at: 0) + await loader.completeImageLoadingWithError(at: 1) XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url], "Expected only two image URL requests before retry action") view0?.simulateRetryAction() @@ -408,7 +408,7 @@ class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url, image0.url, image1.url], "Expected fourth imageURL request after second view retry action") } - func test_feedImageView_preloadsImageURLWhenNearVisible() { + func test_feedImageView_preloadsImageURLWhenNearVisible() async { let image0 = makeImage(url: URL(string: "http://url-0.com")!) let image1 = makeImage(url: URL(string: "http://url-1.com")!) let (sut, loader) = makeSUT() @@ -416,7 +416,7 @@ class FeedUIIntegrationTests: XCTestCase { sut.simulateAppearance() XCTAssertEqual(loader.loadedImageURLs, [], "Expected no image URL requests until image is near visible") - loader.completeFeedLoading(with: [image0, image1]) + await loader.completeFeedLoading(with: [image0, image1]) sut.simulateFeedImageViewNearVisible(at: 0) XCTAssertEqual(loader.loadedImageURLs, [image0.url], "Expected first image URL request once first image is near visible") @@ -430,7 +430,7 @@ class FeedUIIntegrationTests: XCTestCase { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [image0, image1]) + await loader.completeFeedLoading(with: [image0, image1]) XCTAssertEqual(loader.cancelledImageURLs, [], "Expected no cancelled image URL requests until image is not near visible") sut.simulateFeedImageViewNotNearVisible(at: 0) @@ -444,11 +444,11 @@ class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(loader.cancelledImageURLs, [image0.url, image1.url], "Expected second cancelled image URL request once second image is not near visible anymore") } - func test_feedImageView_configuresViewCorrectlyWhenTransitioningFromNearVisibleToVisibleWhileStillPreloadingImage() { + func test_feedImageView_configuresViewCorrectlyWhenTransitioningFromNearVisibleToVisibleWhileStillPreloadingImage() async { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [makeImage()]) + await loader.completeFeedLoading(with: [makeImage()]) sut.simulateFeedImageViewNearVisible(at: 0) let view0 = sut.simulateFeedImageViewVisible(at: 0) @@ -458,18 +458,18 @@ class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(view0?.isShowingImageLoadingIndicator, true, "Expected loading indicator when view becomes visible while still preloading image") let imageData = UIImage.make(withColor: .red).pngData()! - loader.completeImageLoading(with: imageData, at: 0) + await loader.completeImageLoading(with: imageData, at: 0) XCTAssertEqual(view0?.renderedImage, imageData, "Expected rendered image after image preloads successfully") XCTAssertEqual(view0?.isShowingRetryAction, false, "Expected no retry action after image preloads successfully") XCTAssertEqual(view0?.isShowingImageLoadingIndicator, false, "Expected no loading indicator after image preloads successfully") } - func test_feedImageView_configuresViewCorrectlyWhenCellBecomingVisibleAgain() { + func test_feedImageView_configuresViewCorrectlyWhenCellBecomingVisibleAgain() async { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [makeImage()]) + await loader.completeFeedLoading(with: [makeImage()]) let view0 = sut.simulateFeedImageBecomingVisibleAgain(at: 0) @@ -478,33 +478,33 @@ class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(view0?.isShowingImageLoadingIndicator, true, "Expected loading indicator when view becomes visible again") let imageData = UIImage.make(withColor: .red).pngData()! - loader.completeImageLoading(with: imageData, at: 1) + await loader.completeImageLoading(with: imageData, at: 1) XCTAssertEqual(view0?.renderedImage, imageData, "Expected rendered image when image loads successfully after view becomes visible again") XCTAssertEqual(view0?.isShowingRetryAction, false, "Expected no retry when image loads successfully after view becomes visible again") XCTAssertEqual(view0?.isShowingImageLoadingIndicator, false, "Expected no loading indicator when image loads successfully after view becomes visible again") } - func test_feedImageView_doesNotShowDataFromPreviousRequestWhenCellIsReused() throws { + func test_feedImageView_doesNotShowDataFromPreviousRequestWhenCellIsReused() async throws { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [makeImage(), makeImage()]) + await loader.completeFeedLoading(with: [makeImage(), makeImage()]) let view0 = try XCTUnwrap(sut.simulateFeedImageViewVisible(at: 0)) view0.prepareForReuse() let imageData0 = UIImage.make(withColor: .red).pngData()! - loader.completeImageLoading(with: imageData0, at: 0) + await loader.completeImageLoading(with: imageData0, at: 0) XCTAssertEqual(view0.renderedImage, .none, "Expected no image state change for reused view once image loading completes successfully") } - func test_feedImageView_showsDataForNewViewRequestAfterPreviousViewIsReused() throws { + func test_feedImageView_showsDataForNewViewRequestAfterPreviousViewIsReused() async throws { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [makeImage(), makeImage()]) + await loader.completeFeedLoading(with: [makeImage(), makeImage()]) let previousView = try XCTUnwrap(sut.simulateFeedImageViewNotVisible(at: 0)) @@ -512,27 +512,27 @@ class FeedUIIntegrationTests: XCTestCase { previousView.prepareForReuse() let imageData = UIImage.make(withColor: .red).pngData()! - loader.completeImageLoading(with: imageData, at: 1) + await loader.completeImageLoading(with: imageData, at: 1) XCTAssertEqual(newView.renderedImage, imageData) } - func test_feedImageView_doesNotRenderLoadedImageWhenNotVisibleAnymore() { + func test_feedImageView_doesNotRenderLoadedImageWhenNotVisibleAnymore() async { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [makeImage()]) + await loader.completeFeedLoading(with: [makeImage()]) let view = sut.simulateFeedImageViewNotVisible(at: 0) - loader.completeImageLoading(with: anyImageData()) + await loader.completeImageLoading(with: anyImageData()) XCTAssertNil(view?.renderedImage, "Expected no rendered image when an image load finishes after the view is not visible anymore") } - func test_feedImageView_doesNotLoadImageAgainUntilPreviousRequestCompletes() { + func test_feedImageView_doesNotLoadImageAgainUntilPreviousRequestCompletes() async throws { let image = makeImage(url: URL(string: "http://url-0.com")!) let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [image]) + await loader.completeFeedLoading(with: [image]) sut.simulateFeedImageViewNearVisible(at: 0) XCTAssertEqual(loader.loadedImageURLs, [image.url], "Expected first request when near visible") @@ -540,16 +540,19 @@ class FeedUIIntegrationTests: XCTestCase { sut.simulateFeedImageViewVisible(at: 0) XCTAssertEqual(loader.loadedImageURLs, [image.url], "Expected no request until previous completes") - loader.completeImageLoading(at: 0) + await loader.completeImageLoading(at: 0) sut.simulateFeedImageViewVisible(at: 0) XCTAssertEqual(loader.loadedImageURLs, [image.url, image.url], "Expected second request when visible after previous complete") sut.simulateFeedImageViewNotVisible(at: 0) + let result = try await loader.imageResult(at: 1) + XCTAssertEqual(result, .cancelled) + sut.simulateFeedImageViewVisible(at: 0) XCTAssertEqual(loader.loadedImageURLs, [image.url, image.url, image.url], "Expected third request when visible after canceling previous complete") sut.simulateLoadMoreFeedAction() - loader.completeLoadMore(with: [image, makeImage()]) + await loader.completeLoadMore(with: [image, makeImage()]) sut.simulateFeedImageViewVisible(at: 0) XCTAssertEqual(loader.loadedImageURLs, [image.url, image.url, image.url], "Expected no request until previous completes") } @@ -563,7 +566,7 @@ class FeedUIIntegrationTests: XCTestCase { ) -> (sut: ListViewController, loader: LoaderSpy) { let loader = LoaderSpy() let sut = FeedUIComposer.feedComposedWith( - feedLoader: loader.loadPublisher, + feedLoader: loader.loadFeed, imageLoader: loader.loadImageData, selection: selection ) diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift index cc6a4f67..593578bb 100644 --- a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift +++ b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift @@ -5,7 +5,6 @@ import Foundation import EssentialFeed import EssentialFeediOS -import Combine extension FeedUIIntegrationTests { @@ -14,53 +13,56 @@ extension FeedUIIntegrationTests { // MARK: - FeedLoader - private var feedRequests = [PassthroughSubject, Error>]() + private var feedLoader = EssentialAppTests.LoaderSpy>() var loadFeedCallCount: Int { - return feedRequests.count + return feedLoader.requests.count } - func loadPublisher() -> AnyPublisher, Error> { - let publisher = PassthroughSubject, Error>() - feedRequests.append(publisher) - return publisher.eraseToAnyPublisher() + func loadFeed() async throws -> Paginated { + try await feedLoader.load(()) } - func completeFeedLoadingWithError(at index: Int = 0) { - feedRequests[index].send(completion: .failure(anyNSError())) + func completeFeedLoadingWithError(at index: Int = 0) async { + await feedLoader.fail(with: anyNSError(), at: index) } - func completeFeedLoading(with feed: [FeedImage] = [], at index: Int = 0) { - feedRequests[index].send(Paginated(items: feed, loadMorePublisher: { [weak self] in - self?.loadMorePublisher() ?? Empty().eraseToAnyPublisher() - })) - feedRequests[index].send(completion: .finished) + func completeFeedLoading(with feed: [FeedImage] = [], at index: Int = 0) async { + await feedLoader.complete( + with: Paginated( + items: feed, + loadMore: { @MainActor [weak self] in + try await self?.loadMore() ?? Paginated(items: []) + }), + at: index) } // MARK: - LoadMoreFeedLoader - private var loadMoreRequests = [PassthroughSubject, Error>]() + private var loadMoreLoader = EssentialAppTests.LoaderSpy>() var loadMoreCallCount: Int { - return loadMoreRequests.count + return loadMoreLoader.requests.count } - func loadMorePublisher() -> AnyPublisher, Error> { - let publisher = PassthroughSubject, Error>() - loadMoreRequests.append(publisher) - return publisher.eraseToAnyPublisher() + func loadMore() async throws -> Paginated { + try await loadMoreLoader.load(()) } - func completeLoadMore(with feed: [FeedImage] = [], lastPage: Bool = false, at index: Int = 0) { - loadMoreRequests[index].send(Paginated( - items: feed, - loadMorePublisher: lastPage ? nil : { [weak self] in - self?.loadMorePublisher() ?? Empty().eraseToAnyPublisher() - })) + func completeLoadMore(with feed: [FeedImage] = [], lastPage: Bool = false, at index: Int = 0) async { + let loadMore: @Sendable () async throws -> Paginated = { @MainActor [weak self] in + try await self?.loadMore() ?? Paginated(items: []) + } + + await loadMoreLoader.complete( + with: Paginated( + items: feed, + loadMore: lastPage ? nil : loadMore), + at: index) } - func completeLoadMoreWithError(at index: Int = 0) { - loadMoreRequests[index].send(completion: .failure(anyNSError())) + func completeLoadMoreWithError(at index: Int = 0) async { + await loadMoreLoader.fail(with: anyNSError(), at: index) } // MARK: - FeedImageDataLoader @@ -82,12 +84,12 @@ extension FeedUIIntegrationTests { try await imageLoader.load(url) } - func completeImageLoading(with imageData: Data = Data(), at index: Int = 0) { - imageLoader.complete(with: imageData, at: index) + func completeImageLoading(with imageData: Data = Data(), at index: Int = 0) async { + await imageLoader.complete(with: imageData, at: index) } - func completeImageLoadingWithError(at index: Int = 0) { - imageLoader.fail(with: anyNSError(), at: index) + func completeImageLoadingWithError(at index: Int = 0) async { + await imageLoader.fail(with: anyNSError(), at: index) } func imageResult(at index: Int, timeout: TimeInterval = 1) async throws -> AsyncResult { @@ -96,6 +98,8 @@ extension FeedUIIntegrationTests { func cancelPendingRequests() async throws { try await imageLoader.cancelPendingRequests() + try await feedLoader.cancelPendingRequests() + try await loadMoreLoader.cancelPendingRequests() } } diff --git a/EssentialApp/EssentialAppTests/Helpers/LoaderSpy.swift b/EssentialApp/EssentialAppTests/Helpers/LoaderSpy.swift index fca70363..1d95d04a 100644 --- a/EssentialApp/EssentialAppTests/Helpers/LoaderSpy.swift +++ b/EssentialApp/EssentialAppTests/Helpers/LoaderSpy.swift @@ -43,17 +43,17 @@ class LoaderSpy { } } - func complete(with resource: Resource, at index: Int) { + func complete(with resource: Resource, at index: Int) async { requests[index].continuation.yield(resource) requests[index].continuation.finish() - while requests[index].result == nil { RunLoop.current.run(until: Date()) } + while requests[index].result == nil { await Task.yield() } } - func fail(with error: Error, at index: Int) { + func fail(with error: Error, at index: Int) async { requests[index].continuation.finish(throwing: error) - while requests[index].result == nil { RunLoop.current.run(until: Date()) } + while requests[index].result == nil { await Task.yield() } } func result(at index: Int, timeout: TimeInterval = 1) async throws -> AsyncResult { diff --git a/EssentialFeed/EssentialFeed/Shared API/Paginated.swift b/EssentialFeed/EssentialFeed/Shared API/Paginated.swift index b471025e..e88f8979 100644 --- a/EssentialFeed/EssentialFeed/Shared API/Paginated.swift +++ b/EssentialFeed/EssentialFeed/Shared API/Paginated.swift @@ -4,13 +4,11 @@ import Foundation -public struct Paginated { - public typealias LoadMoreCompletion = (Result) -> Void - +public struct Paginated: Sendable { public let items: [Item] - public let loadMore: ((@escaping LoadMoreCompletion) -> Void)? + public let loadMore: (@Sendable () async throws -> Self)? - public init(items: [Item], loadMore: ((@escaping LoadMoreCompletion) -> Void)? = nil) { + public init(items: [Item], loadMore: (@Sendable () async throws -> Self)? = nil) { self.items = items self.loadMore = loadMore }