From 7b1c0f348037e33d28a751c76b63edbb502a6e1d Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Mon, 3 Mar 2025 16:57:30 -0300 Subject: [PATCH 01/13] Configures feed navigation as window root view controller --- .../EssentialApp.xcodeproj/project.pbxproj | 4 +-- EssentialApp/EssentialApp/SceneDelegate.swift | 27 +++++++++++-------- .../SceneDelegateTests.swift | 26 ++++++++++++++++++ 3 files changed, 44 insertions(+), 13 deletions(-) create mode 100644 EssentialApp/EssentialAppTests/SceneDelegateTests.swift diff --git a/EssentialApp/EssentialApp.xcodeproj/project.pbxproj b/EssentialApp/EssentialApp.xcodeproj/project.pbxproj index df4150f..4540d98 100644 --- a/EssentialApp/EssentialApp.xcodeproj/project.pbxproj +++ b/EssentialApp/EssentialApp.xcodeproj/project.pbxproj @@ -472,7 +472,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -529,7 +529,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index 097e004..c9a437e 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -18,6 +18,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let _ = (scene as? UIWindowScene) else { return } + configureWindow() + } + + func configureWindow() { let remoteURL = URL(string: "https://ile-api.essentialdeveloper.com/essential-feed/v1/feed")! let remoteClient = makeRemoteClient() @@ -28,17 +32,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let localFeedLoader = LocalFeedLoader(store: localStore, currentDate: Date.init) let localImageLoader = LocalFeedImageDataLoader(store: localStore) - window?.rootViewController = FeedUIComposer.feedComposedWith( - feedLoader: FeedLoaderWithFallbackComposite( - primary: FeedLoaderCacheDecorator( - decoratee: remoteFeedLoader, - cache: localFeedLoader), - fallback: localFeedLoader), - imageLoader: FeedImageDataLoaderWithFallbackComposite( - primary: localImageLoader, - fallback: FeedImageDataLoaderCacheDecorator( - decoratee: remoteImageLoader, - cache: localImageLoader))) + window?.rootViewController = UINavigationController( + rootViewController: FeedUIComposer.feedComposedWith( + feedLoader: FeedLoaderWithFallbackComposite( + primary: FeedLoaderCacheDecorator( + decoratee: remoteFeedLoader, + cache: localFeedLoader), + fallback: localFeedLoader), + imageLoader: FeedImageDataLoaderWithFallbackComposite( + primary: localImageLoader, + fallback: FeedImageDataLoaderCacheDecorator( + decoratee: remoteImageLoader, + cache: localImageLoader)))) } func makeRemoteClient() -> HTTPClient { diff --git a/EssentialApp/EssentialAppTests/SceneDelegateTests.swift b/EssentialApp/EssentialAppTests/SceneDelegateTests.swift new file mode 100644 index 0000000..5af9cb5 --- /dev/null +++ b/EssentialApp/EssentialAppTests/SceneDelegateTests.swift @@ -0,0 +1,26 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeediOS +@testable import EssentialApp + +class SceneDelegateTests: XCTestCase { + + func test_sceneWillConnectToSession_configuresRootViewController() { + let sut = SceneDelegate() + sut.window = UIWindow() + + sut.configureWindow() + + let root = sut.window?.rootViewController + let rootNavigation = root as? UINavigationController + let topController = rootNavigation?.topViewController + + XCTAssertNotNil(rootNavigation, "Expected a navigation controller as root, got \(String(describing: root)) instead") + XCTAssertTrue(topController is FeedViewController, "Expected a feed controller as top view controller, got \(String(describing: topController)) instead") + } + +} From c771a2ff1cb73a2a98bbc43658bb1f17f7349251 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 5 Mar 2025 21:20:02 -0300 Subject: [PATCH 02/13] Move Feed UI composition details from the EssentialFeediOS to the EssentialApp module (Composition Root) --- ...edImageDataLoaderPresentationAdapter.swift | 39 ++ .../FeedLoaderPresentationAdapter.swift | 30 ++ .../EssentialApp/FeedUIComposer.swift | 39 ++ .../EssentialApp/FeedViewAdapter.swift | 31 ++ .../MainQueueDispatchDecorator.swift | 39 ++ .../EssentialApp/WeakRefVirtualProxy.swift | 34 ++ .../FeedUIIntegrationTests.swift | 348 ++++++++++++++++++ .../Helpers/FeedImageCell+TestHelpers.swift | 37 ++ .../FeedUIIntegrationTests+Assertions.swift | 37 ++ .../FeedUIIntegrationTests+LoaderSpy.swift | 67 ++++ .../FeedUIIntegrationTests+Localization.swift | 20 + .../FeedViewController+TestHelpers.swift | 91 +++++ .../Helpers/UIButton+TestHelpers.swift | 12 + .../Helpers/UIControl+TestHelpers.swift | 16 + .../Helpers/UIImage+TestHelpers.swift | 19 + .../UIRefreshControl+TestHelpers.swift | 12 + .../Helpers/UIRefreshControlSpy.swift | 20 + .../EssentialFeed.xcodeproj/project.pbxproj | 92 ----- .../Controllers/FeedImageCellController.swift | 8 +- .../Controllers/FeedViewController.swift | 11 +- 20 files changed, 903 insertions(+), 99 deletions(-) create mode 100644 EssentialApp/EssentialApp/FeedImageDataLoaderPresentationAdapter.swift create mode 100644 EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift create mode 100644 EssentialApp/EssentialApp/FeedUIComposer.swift create mode 100644 EssentialApp/EssentialApp/FeedViewAdapter.swift create mode 100644 EssentialApp/EssentialApp/MainQueueDispatchDecorator.swift create mode 100644 EssentialApp/EssentialApp/WeakRefVirtualProxy.swift create mode 100644 EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift create mode 100644 EssentialApp/EssentialAppTests/Helpers/FeedImageCell+TestHelpers.swift create mode 100644 EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Assertions.swift create mode 100644 EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift create mode 100644 EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift create mode 100644 EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift create mode 100644 EssentialApp/EssentialAppTests/Helpers/UIButton+TestHelpers.swift create mode 100644 EssentialApp/EssentialAppTests/Helpers/UIControl+TestHelpers.swift create mode 100644 EssentialApp/EssentialAppTests/Helpers/UIImage+TestHelpers.swift create mode 100644 EssentialApp/EssentialAppTests/Helpers/UIRefreshControl+TestHelpers.swift create mode 100644 EssentialApp/EssentialAppTests/Helpers/UIRefreshControlSpy.swift diff --git a/EssentialApp/EssentialApp/FeedImageDataLoaderPresentationAdapter.swift b/EssentialApp/EssentialApp/FeedImageDataLoaderPresentationAdapter.swift new file mode 100644 index 0000000..21f9b9d --- /dev/null +++ b/EssentialApp/EssentialApp/FeedImageDataLoaderPresentationAdapter.swift @@ -0,0 +1,39 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import EssentialFeed +import EssentialFeediOS + +final class FeedImageDataLoaderPresentationAdapter: FeedImageCellControllerDelegate where View.Image == Image { + private let model: FeedImage + private let imageLoader: FeedImageDataLoader + private var task: FeedImageDataLoaderTask? + + var presenter: FeedImagePresenter? + + init(model: FeedImage, imageLoader: FeedImageDataLoader) { + self.model = model + self.imageLoader = imageLoader + } + + func didRequestImage() { + presenter?.didStartLoadingImageData(for: model) + + let model = self.model + task = imageLoader.loadImageData(from: model.url) { [weak self] result in + switch result { + case let .success(data): + self?.presenter?.didFinishLoadingImageData(with: data, for: model) + + case let .failure(error): + self?.presenter?.didFinishLoadingImageData(with: error, for: model) + } + } + } + + func didCancelImageRequest() { + task?.cancel() + } +} diff --git a/EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift b/EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift new file mode 100644 index 0000000..e451977 --- /dev/null +++ b/EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift @@ -0,0 +1,30 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import EssentialFeed +import EssentialFeediOS + +final class FeedLoaderPresentationAdapter: FeedViewControllerDelegate { + private let feedLoader: FeedLoader + var presenter: FeedPresenter? + + init(feedLoader: FeedLoader) { + self.feedLoader = feedLoader + } + + func didRequestFeedRefresh() { + presenter?.didStartLoadingFeed() + + feedLoader.load { [weak self] result in + switch result { + case let .success(feed): + self?.presenter?.didFinishLoadingFeed(with: feed) + + case let .failure(error): + self?.presenter?.didFinishLoadingFeed(with: error) + } + } + } +} diff --git a/EssentialApp/EssentialApp/FeedUIComposer.swift b/EssentialApp/EssentialApp/FeedUIComposer.swift new file mode 100644 index 0000000..efa25f5 --- /dev/null +++ b/EssentialApp/EssentialApp/FeedUIComposer.swift @@ -0,0 +1,39 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2024 PortoCode. All Rights Reserved. +// + +import UIKit +import EssentialFeed +import EssentialFeediOS + +public final class FeedUIComposer { + private init() {} + + public static func feedComposedWith(feedLoader: FeedLoader, imageLoader: FeedImageDataLoader) -> FeedViewController { + let presentationAdapter = FeedLoaderPresentationAdapter( + feedLoader: MainQueueDispatchDecorator(decoratee: feedLoader)) + + let feedController = makeFeedViewController( + delegate: presentationAdapter, + title: FeedPresenter.title) + + presentationAdapter.presenter = FeedPresenter( + feedView: FeedViewAdapter( + controller: feedController, + imageLoader: MainQueueDispatchDecorator(decoratee: imageLoader)), + loadingView: WeakRefVirtualProxy(feedController), + errorView: WeakRefVirtualProxy(feedController)) + + return feedController + } + + private static func makeFeedViewController(delegate: FeedViewControllerDelegate, title: String) -> FeedViewController { + let bundle = Bundle(for: FeedViewController.self) + let storyboard = UIStoryboard(name: "Feed", bundle: bundle) + let feedController = storyboard.instantiateInitialViewController() as! FeedViewController + feedController.delegate = delegate + feedController.title = title + return feedController + } +} diff --git a/EssentialApp/EssentialApp/FeedViewAdapter.swift b/EssentialApp/EssentialApp/FeedViewAdapter.swift new file mode 100644 index 0000000..a9328d1 --- /dev/null +++ b/EssentialApp/EssentialApp/FeedViewAdapter.swift @@ -0,0 +1,31 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import UIKit +import EssentialFeed +import EssentialFeediOS + +final class FeedViewAdapter: FeedView { + private weak var controller: FeedViewController? + private let imageLoader: FeedImageDataLoader + + init(controller: FeedViewController? = nil, imageLoader: FeedImageDataLoader) { + self.controller = controller + self.imageLoader = imageLoader + } + + func display(_ viewModel: FeedViewModel) { + controller?.display(viewModel.feed.map { model in + let adapter = FeedImageDataLoaderPresentationAdapter, UIImage>(model: model, imageLoader: imageLoader) + let view = FeedImageCellController(delegate: adapter) + + adapter.presenter = FeedImagePresenter( + view: WeakRefVirtualProxy(view), + imageTransformer: UIImage.init) + + return view + }) + } +} diff --git a/EssentialApp/EssentialApp/MainQueueDispatchDecorator.swift b/EssentialApp/EssentialApp/MainQueueDispatchDecorator.swift new file mode 100644 index 0000000..4c0cea3 --- /dev/null +++ b/EssentialApp/EssentialApp/MainQueueDispatchDecorator.swift @@ -0,0 +1,39 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation +import EssentialFeed + +final class MainQueueDispatchDecorator { + private let decoratee: T + + init(decoratee: T) { + self.decoratee = decoratee + } + + func dispatch(completion: @escaping () -> Void) { + guard Thread.isMainThread else { + return DispatchQueue.main.async(execute: completion) + } + + completion() + } +} + +extension MainQueueDispatchDecorator: FeedLoader where T == FeedLoader { + func load(completion: @escaping (FeedLoader.Result) -> Void) { + decoratee.load { [weak self] result in + self?.dispatch { completion(result) } + } + } +} + +extension MainQueueDispatchDecorator: FeedImageDataLoader where T == FeedImageDataLoader { + func loadImageData(from url: URL, completion: @escaping (FeedImageDataLoader.Result) -> Void) -> FeedImageDataLoaderTask { + return decoratee.loadImageData(from: url) { [weak self] result in + self?.dispatch { completion(result) } + } + } +} diff --git a/EssentialApp/EssentialApp/WeakRefVirtualProxy.swift b/EssentialApp/EssentialApp/WeakRefVirtualProxy.swift new file mode 100644 index 0000000..594dbde --- /dev/null +++ b/EssentialApp/EssentialApp/WeakRefVirtualProxy.swift @@ -0,0 +1,34 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import UIKit +import EssentialFeed +import EssentialFeediOS + +final class WeakRefVirtualProxy { + private weak var object: T? + + init(_ object: T) { + self.object = object + } +} + +extension WeakRefVirtualProxy: FeedErrorView where T: FeedErrorView { + func display(_ viewModel: FeedErrorViewModel) { + object?.display(viewModel) + } +} + +extension WeakRefVirtualProxy: FeedLoadingView where T: FeedLoadingView { + func display(_ viewModel: FeedLoadingViewModel) { + object?.display(viewModel) + } +} + +extension WeakRefVirtualProxy: FeedImageView where T: FeedImageView, T.Image == UIImage { + func display(_ model: FeedImageViewModel) { + object?.display(model) + } +} diff --git a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift new file mode 100644 index 0000000..919cd16 --- /dev/null +++ b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift @@ -0,0 +1,348 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2024 PortoCode. All Rights Reserved. +// + +import XCTest +import UIKit +import EssentialApp +import EssentialFeed +import EssentialFeediOS + +final class FeedUIIntegrationTests: XCTestCase { + + func test_feedView_hasTitle() { + let (sut, _) = makeSUT() + + sut.simulateAppearance() + + XCTAssertEqual(sut.title, localized("FEED_VIEW_TITLE")) + } + + func test_loadFeedActions_requestFeedFromLoader() { + let (sut, loader) = makeSUT() + XCTAssertEqual(loader.loadFeedCallCount, 0, "Expected no loading requests before view appears") + + sut.simulateAppearance() + XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected a loading request once view appears") + + sut.simulateUserInitiatedFeedReload() + XCTAssertEqual(loader.loadFeedCallCount, 2, "Expected another loading request once user initiates a reload") + + sut.simulateUserInitiatedFeedReload() + XCTAssertEqual(loader.loadFeedCallCount, 3, "Expected yet another loading request once user initiates another reload") + } + + func test_loadingFeedIndicator_isVisibleWhileLoadingFeed() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + XCTAssertTrue(sut.isShowingLoadingIndicator, "Expected loading indicator once view appears") + + loader.completeFeedLoading(at: 0) + XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once loading completes successfully") + + sut.simulateUserInitiatedFeedReload() + XCTAssertTrue(sut.isShowingLoadingIndicator, "Expected loading indicator once user initiates a reload") + + loader.completeFeedLoadingWithError(at: 1) + XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once user initiated loading completes with error") + } + + func test_loadFeedCompletion_rendersSuccessfullyLoadedFeed() { + 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) + let image3 = makeImage(description: nil, location: nil) + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + assertThat(sut, isRendering: []) + + loader.completeFeedLoading(with: [image0], at: 0) + assertThat(sut, isRendering: [image0]) + + sut.simulateUserInitiatedFeedReload() + loader.completeFeedLoading(with: [image0, image1, image2, image3], at: 1) + assertThat(sut, isRendering: [image0, image1, image2, image3]) + } + + func test_loadFeedCompletion_doesNotAlterCurrentRenderingStateOnError() { + let image0 = makeImage() + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [image0], at: 0) + assertThat(sut, isRendering: [image0]) + + sut.simulateUserInitiatedFeedReload() + loader.completeFeedLoadingWithError(at: 1) + assertThat(sut, isRendering: [image0]) + } + + func test_loadFeedCompletion_rendersErrorMessageOnErrorUntilNextReload() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + XCTAssertEqual(sut.errorMessage, nil) + + loader.completeFeedLoadingWithError(at: 0) + XCTAssertEqual(sut.errorMessage, localized("FEED_VIEW_CONNECTION_ERROR")) + + sut.simulateUserInitiatedFeedReload() + XCTAssertEqual(sut.errorMessage, nil) + } + + func test_feedImageView_loadsImageURLWhenVisible() { + 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]) + + XCTAssertEqual(loader.loadedImageURLs, [], "Expected no image URL requests until views become visible") + + sut.simulateFeedImageViewVisible(at: 0) + XCTAssertEqual(loader.loadedImageURLs, [image0.url], "Expected first image URL request once first view becomes visible") + + sut.simulateFeedImageViewVisible(at: 1) + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url], "Expected second image URL request once second view also becomes visible") + } + + func test_feedImageView_cancelsImageLoadingWhenNotVisibleAnymore() { + 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]) + XCTAssertEqual(loader.cancelledImageURLs, [], "Expected no cancelled image URL requests until image is not visible") + + sut.simulateFeedImageViewNotVisible(at: 0) + XCTAssertEqual(loader.cancelledImageURLs, [image0.url], "Expected one cancelled image URL request once first image is not visible anymore") + + sut.simulateFeedImageViewNotVisible(at: 1) + XCTAssertEqual(loader.cancelledImageURLs, [image0.url, image1.url], "Expected two cancelled image URL requests once second image is also not visible anymore") + } + + func test_feedImageViewLoadingIndicator_isVisibleWhileLoadingImage() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + 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) + 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) + 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") + } + + func test_feedImageView_rendersImageLoadedFromURL() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [makeImage(), makeImage()]) + + let view0 = sut.simulateFeedImageViewVisible(at: 0) + let view1 = sut.simulateFeedImageViewVisible(at: 1) + XCTAssertEqual(view0?.renderedImage, .none, "Expected no image for first view while loading first image") + 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) + 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) + 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() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [makeImage(), makeImage()]) + + let view0 = sut.simulateFeedImageViewVisible(at: 0) + let view1 = sut.simulateFeedImageViewVisible(at: 1) + XCTAssertEqual(view0?.isShowingRetryAction, false, "Expected no retry action for first view while loading first image") + 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) + 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) + 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") + } + + func test_feedImageViewRetryButton_isVisibleOnInvalidImageData() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + 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) + XCTAssertEqual(view?.isShowingRetryAction, true, "Expected retry action once image loading completes with invalid image data") + } + + func test_feedImageViewRetryAction_retriesImageLoad() { + 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]) + + 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) + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url], "Expected only two image URL requests before retry action") + + view0?.simulateRetryAction() + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url, image0.url], "Expected third imageURL request after first view retry action") + + view1?.simulateRetryAction() + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url, image0.url, image1.url], "Expected fourth imageURL request after second view retry action") + } + + func test_feedImageView_preloadsImageURLWhenNearVisible() { + 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]) + XCTAssertEqual(loader.loadedImageURLs, [], "Expected no image URL requests until image is near visible") + + sut.simulateFeedImageViewNearVisible(at: 0) + XCTAssertEqual(loader.loadedImageURLs, [image0.url], "Expected first image URL request once first image is near visible") + + sut.simulateFeedImageViewNearVisible(at: 1) + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url], "Expected second image URL request once second image is near visible") + } + + func test_feedImageView_cancelsImageURLPreloadingWhenNotNearVisibleAnymore() { + 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]) + XCTAssertEqual(loader.cancelledImageURLs, [], "Expected no cancelled image URL requests until image is not near visible") + + sut.simulateFeedImageViewNotNearVisible(at: 0) + XCTAssertEqual(loader.cancelledImageURLs, [image0.url], "Expected first cancelled image URL request once first image is not near visible anymore") + + sut.simulateFeedImageViewNotNearVisible(at: 1) + XCTAssertEqual(loader.cancelledImageURLs, [image0.url, image1.url], "Expected second cancelled image URL request once second image is not near visible anymore") + } + + func test_feedImageView_doesNotShowDataFromPreviousRequestWhenCellIsReused() throws { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [makeImage(), makeImage()]) + + let view = try XCTUnwrap(sut.simulateFeedImageViewVisible(at: 0)) + view.prepareForReuse() + + loader.completeImageLoading(with: anyImageData(), at: 0) + + XCTAssertEqual(view.renderedImage, .none, "Expected no image state change for reused view once image loading completes successfully") + } + + func test_feedImageView_showsDataForNewViewRequestAfterPreviousViewIsReused() throws { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [makeImage(), makeImage()]) + + let previousView = try XCTUnwrap(sut.simulateFeedImageViewNotVisible(at: 0)) + let newView = try XCTUnwrap(sut.simulateFeedImageViewVisible(at: 0)) + previousView.prepareForReuse() + + let imageData = anyImageData() + loader.completeImageLoading(with: imageData, at: 1) + + XCTAssertEqual(newView.renderedImage, imageData) + } + + func test_feedImageView_doesNotRenderLoadedImageWhenNotVisibleAnymore() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [makeImage()]) + + let view = sut.simulateFeedImageViewNotVisible(at: 0) + 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_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_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) + } + + // MARK: - Helpers + + private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: FeedViewController, loader: LoaderSpy) { + let loader = LoaderSpy() + let sut = FeedUIComposer.feedComposedWith(feedLoader: loader, imageLoader: loader) + trackForMemoryLeaks(loader, file: file, line: line) + trackForMemoryLeaks(sut, file: file, line: line) + return (sut, loader) + } + + private func makeImage(description: String? = nil, location: String? = nil, url: URL = URL(string: "http://any-url.com")!) -> FeedImage { + return FeedImage(id: UUID(), description: description, location: location, url: url) + } + + private func anyImageData() -> Data { + return UIImage.make(withColor: .red).pngData()! + } + +} diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedImageCell+TestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/FeedImageCell+TestHelpers.swift new file mode 100644 index 0000000..e3e84a6 --- /dev/null +++ b/EssentialApp/EssentialAppTests/Helpers/FeedImageCell+TestHelpers.swift @@ -0,0 +1,37 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2024 PortoCode. All Rights Reserved. +// + +import UIKit +import EssentialFeediOS + +extension FeedImageCell { + func simulateRetryAction() { + feedImageRetryButton.simulateTap() + } + + var isShowingLocation: Bool { + return !locationContainer.isHidden + } + + var isShowingImageLoadingIndicator: Bool { + return feedImageContainer.isShimmering + } + + var isShowingRetryAction: Bool { + return !feedImageRetryButton.isHidden + } + + var locationText: String? { + return locationLabel.text + } + + var descriptionText: String? { + return descriptionLabel.text + } + + var renderedImage: Data? { + return feedImageView.image?.pngData() + } +} diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Assertions.swift b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Assertions.swift new file mode 100644 index 0000000..6310627 --- /dev/null +++ b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Assertions.swift @@ -0,0 +1,37 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2024 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeed +import EssentialFeediOS + +extension FeedUIIntegrationTests { + + func assertThat(_ sut: FeedViewController, isRendering feed: [FeedImage], file: StaticString = #file, line: UInt = #line) { + guard sut.numberOfRenderedFeedImageViews() == feed.count else { + return XCTFail("Expected \(feed.count) images, got \(sut.numberOfRenderedFeedImageViews()) instead.", file: file, line: line) + } + + feed.enumerated().forEach { index, image in + assertThat(sut, hasViewConfiguredFor: image, at: index, file: file, line: line) + } + } + + func assertThat(_ sut: FeedViewController, hasViewConfiguredFor image: FeedImage, at index: Int, file: StaticString = #file, line: UInt = #line) { + let view = sut.feedImageView(at: index) + + guard let cell = view as? FeedImageCell else { + return XCTFail("Expected \(FeedImageCell.self) instance, got \(String(describing: view)) instead", file: file, line: line) + } + + let shouldLocationBeVisible = (image.location != nil) + XCTAssertEqual(cell.isShowingLocation, shouldLocationBeVisible, "Expected `isShowingLocation` to be \(shouldLocationBeVisible) for image view at index (\(index))", file: file, line: line) + + XCTAssertEqual(cell.locationText, image.location, "Expected location text to be \(String(describing: image.location)) for image view at index (\(index))", file: file, line: line) + + XCTAssertEqual(cell.descriptionText, image.description, "Expected description text to be \(String(describing: image.description)) for image view at index (\(index)", file: file, line: line) + } + +} diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift new file mode 100644 index 0000000..5d49ea0 --- /dev/null +++ b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift @@ -0,0 +1,67 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2024 PortoCode. All Rights Reserved. +// + +import Foundation +import EssentialFeed +import EssentialFeediOS + +extension FeedUIIntegrationTests { + + class LoaderSpy: FeedLoader, FeedImageDataLoader { + + // MARK: - FeedLoader + + private var feedRequests = [(FeedLoader.Result) -> Void]() + + var loadFeedCallCount: Int { + return feedRequests.count + } + + func load(completion: @escaping (FeedLoader.Result) -> Void) { + feedRequests.append(completion) + } + + func completeFeedLoading(with feed: [FeedImage] = [], at index: Int = 0) { + feedRequests[index](.success(feed)) + } + + func completeFeedLoadingWithError(at index: Int = 0) { + let error = NSError(domain: "an error", code: 404) + feedRequests[index](.failure(error)) + } + + // MARK: - FeedImageDataLoader + + private struct TaskSpy: FeedImageDataLoaderTask { + let cancelCallback: () -> Void + func cancel() { + cancelCallback() + } + } + + private var imageRequests = [(url: URL, completion: (FeedImageDataLoader.Result) -> Void)]() + + var loadedImageURLs: [URL] { + return imageRequests.map { $0.url } + } + + private(set) var cancelledImageURLs = [URL]() + + func loadImageData(from url: URL, completion: @escaping (FeedImageDataLoader.Result) -> Void) -> FeedImageDataLoaderTask { + imageRequests.append((url, completion)) + return TaskSpy { [weak self] in self?.cancelledImageURLs.append(url) } + } + + func completeImageLoading(with imageData: Data = Data(), at index: Int = 0) { + imageRequests[index].completion(.success(imageData)) + } + + func completeImageLoadingWithError(at index: Int = 0) { + let error = NSError(domain: "an error", code: 404) + imageRequests[index].completion(.failure(error)) + } + } + +} diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift new file mode 100644 index 0000000..268e8d0 --- /dev/null +++ b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift @@ -0,0 +1,20 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation +import XCTest +import EssentialFeed + +extension FeedUIIntegrationTests { + func localized(_ key: String, file: StaticString = #file, line: UInt = #line) -> String { + let table = "Feed" + let bundle = Bundle(for: FeedPresenter.self) + let value = bundle.localizedString(forKey: key, value: nil, table: table) + if value == key { + XCTFail("Missing localized string for key: \(key) in table: \(table)", file: file, line: line) + } + return value + } +} diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift new file mode 100644 index 0000000..f326786 --- /dev/null +++ b/EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift @@ -0,0 +1,91 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2024 PortoCode. All Rights Reserved. +// + +import UIKit +import EssentialFeediOS + +extension FeedViewController { + func simulateAppearance() { + if !isViewLoaded { + loadViewIfNeeded() + prepareForFirstAppearance() + } + + beginAppearanceTransition(true, animated: false) + endAppearanceTransition() + } + + private func prepareForFirstAppearance() { + replaceRefreshControlWithFakeForiOS17PlusSupport() + } + + private func replaceRefreshControlWithFakeForiOS17PlusSupport() { + let spyRefreshControl = UIRefreshControlSpy() + + refreshControl?.allTargets.forEach { target in + refreshControl?.actions(forTarget: target, forControlEvent: .valueChanged)?.forEach { action in + spyRefreshControl.addTarget(target, action: Selector(action), for: .valueChanged) + } + } + + refreshControl = spyRefreshControl + } + + func simulateUserInitiatedFeedReload() { + refreshControl?.simulatePullToRefresh() + } + + @discardableResult + func simulateFeedImageViewVisible(at index: Int) -> FeedImageCell? { + return feedImageView(at: index) as? FeedImageCell + } + + @discardableResult + func simulateFeedImageViewNotVisible(at row: Int) -> FeedImageCell? { + let view = simulateFeedImageViewVisible(at: row) + + let delegate = tableView.delegate + let index = IndexPath(row: row, section: feedImagesSection) + delegate?.tableView?(tableView, didEndDisplaying: view!, forRowAt: index) + + return view + } + + func simulateFeedImageViewNearVisible(at row: Int) { + let ds = tableView.prefetchDataSource + let index = IndexPath(row: row, section: feedImagesSection) + ds?.tableView(tableView, prefetchRowsAt: [index]) + } + + func simulateFeedImageViewNotNearVisible(at row: Int) { + simulateFeedImageViewNearVisible(at: row) + + let ds = tableView.prefetchDataSource + let index = IndexPath(row: row, section: feedImagesSection) + ds?.tableView?(tableView, cancelPrefetchingForRowsAt: [index]) + } + + var errorMessage: String? { + return errorView?.message + } + + var isShowingLoadingIndicator: Bool { + return refreshControl?.isRefreshing == true + } + + func numberOfRenderedFeedImageViews() -> Int { + return tableView.numberOfRows(inSection: feedImagesSection) + } + + func feedImageView(at row: Int) -> UITableViewCell? { + let ds = tableView.dataSource + let index = IndexPath(row: row, section: feedImagesSection) + return ds?.tableView(tableView, cellForRowAt: index) + } + + private var feedImagesSection: Int { + return 0 + } +} diff --git a/EssentialApp/EssentialAppTests/Helpers/UIButton+TestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/UIButton+TestHelpers.swift new file mode 100644 index 0000000..97f4583 --- /dev/null +++ b/EssentialApp/EssentialAppTests/Helpers/UIButton+TestHelpers.swift @@ -0,0 +1,12 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2024 PortoCode. All Rights Reserved. +// + +import UIKit + +extension UIButton { + func simulateTap() { + simulate(event: .touchUpInside) + } +} diff --git a/EssentialApp/EssentialAppTests/Helpers/UIControl+TestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/UIControl+TestHelpers.swift new file mode 100644 index 0000000..df6b1ae --- /dev/null +++ b/EssentialApp/EssentialAppTests/Helpers/UIControl+TestHelpers.swift @@ -0,0 +1,16 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2024 PortoCode. All Rights Reserved. +// + +import UIKit + +extension UIControl { + func simulate(event: UIControl.Event) { + allTargets.forEach { target in + actions(forTarget: target, forControlEvent: event)?.forEach { + (target as NSObject).perform(Selector($0)) + } + } + } +} diff --git a/EssentialApp/EssentialAppTests/Helpers/UIImage+TestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/UIImage+TestHelpers.swift new file mode 100644 index 0000000..5ac1362 --- /dev/null +++ b/EssentialApp/EssentialAppTests/Helpers/UIImage+TestHelpers.swift @@ -0,0 +1,19 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2024 PortoCode. All Rights Reserved. +// + +import UIKit + +extension UIImage { + static func make(withColor color: UIColor) -> UIImage { + let rect = CGRect(x: 0, y: 0, width: 1, height: 1) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + + return UIGraphicsImageRenderer(size: rect.size, format: format).image { rendererContext in + color.setFill() + rendererContext.fill(rect) + } + } +} diff --git a/EssentialApp/EssentialAppTests/Helpers/UIRefreshControl+TestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/UIRefreshControl+TestHelpers.swift new file mode 100644 index 0000000..75550a0 --- /dev/null +++ b/EssentialApp/EssentialAppTests/Helpers/UIRefreshControl+TestHelpers.swift @@ -0,0 +1,12 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2024 PortoCode. All Rights Reserved. +// + +import UIKit + +extension UIRefreshControl { + func simulatePullToRefresh() { + simulate(event: .valueChanged) + } +} diff --git a/EssentialApp/EssentialAppTests/Helpers/UIRefreshControlSpy.swift b/EssentialApp/EssentialAppTests/Helpers/UIRefreshControlSpy.swift new file mode 100644 index 0000000..50309a4 --- /dev/null +++ b/EssentialApp/EssentialAppTests/Helpers/UIRefreshControlSpy.swift @@ -0,0 +1,20 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2024 PortoCode. All Rights Reserved. +// + +import UIKit + +class UIRefreshControlSpy: UIRefreshControl { + private var _isRefreshing = false + + override var isRefreshing: Bool { _isRefreshing } + + override func beginRefreshing() { + _isRefreshing = true + } + + override func endRefreshing() { + _isRefreshing = false + } +} diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index dcfbc84..a3f4745 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -18,7 +18,6 @@ 5B034B452CA3A1A100FB65F8 /* SharedTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B034B442CA3A1A100FB65F8 /* SharedTestHelpers.swift */; }; 5B034B462CA3A1A100FB65F8 /* SharedTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B034B442CA3A1A100FB65F8 /* SharedTestHelpers.swift */; }; 5B06826E2D0A5E59009749F3 /* FeedImageCellController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B06826D2D0A5E59009749F3 /* FeedImageCellController.swift */; }; - 5B0682712D0A8E24009749F3 /* FeedUIComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B0682702D0A8E24009749F3 /* FeedUIComposer.swift */; }; 5B0E220B2BFE2FEA009FC3EB /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B0E220A2BFE2FEA009FC3EB /* HTTPClient.swift */; }; 5B0E220D2BFE3135009FC3EB /* FeedItemsMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B0E220C2BFE3135009FC3EB /* FeedItemsMapper.swift */; }; 5B107E032BF5BB2100927709 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B107DF82BF5BB2100927709 /* EssentialFeed.framework */; }; @@ -32,22 +31,12 @@ 5B304EEA2BFF582400AF431F /* URLSessionHTTPClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B304EE92BFF582400AF431F /* URLSessionHTTPClientTests.swift */; }; 5B4BAE7E2CFBA0EE00CE079A /* EssentialFeediOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B4BAE762CFBA0EE00CE079A /* EssentialFeediOS.framework */; }; 5B4BAE912CFBA12A00CE079A /* EssentialFeediOS.h in Headers */ = {isa = PBXBuildFile; fileRef = 5B4BAE8F2CFBA12A00CE079A /* EssentialFeediOS.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 5B69928F2CFE811200DD47E9 /* FeedUIIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B69928E2CFE811200DD47E9 /* FeedUIIntegrationTests.swift */; }; 5B6992912CFE85E400DD47E9 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B107DF82BF5BB2100927709 /* EssentialFeed.framework */; platformFilter = ios; }; 5B6992922CFE85E400DD47E9 /* EssentialFeed.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5B107DF82BF5BB2100927709 /* EssentialFeed.framework */; platformFilter = ios; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5B6992972CFE8D8B00DD47E9 /* XCTestCase+MemoryLeakTracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8BB9982C02719F00D40D42 /* XCTestCase+MemoryLeakTracking.swift */; }; 5B6992AD2D012C7200DD47E9 /* FeedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992AC2D012C7200DD47E9 /* FeedViewController.swift */; }; 5B6992D72D03F23700DD47E9 /* FeedImageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992D62D03F23700DD47E9 /* FeedImageCell.swift */; }; 5B6992D92D0662B200DD47E9 /* UIView+Shimmering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992D82D0662B200DD47E9 /* UIView+Shimmering.swift */; }; - 5B6992E32D091BED00DD47E9 /* UIImage+TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992E22D091BED00DD47E9 /* UIImage+TestHelpers.swift */; }; - 5B6992E52D091CA600DD47E9 /* UIRefreshControl+TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992E42D091CA600DD47E9 /* UIRefreshControl+TestHelpers.swift */; }; - 5B6992E72D091D5000DD47E9 /* UIButton+TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992E62D091D5000DD47E9 /* UIButton+TestHelpers.swift */; }; - 5B6992E92D091DF400DD47E9 /* UIControl+TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992E82D091DF400DD47E9 /* UIControl+TestHelpers.swift */; }; - 5B6992EE2D091FD700DD47E9 /* FeedUIIntegrationTests+TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992ED2D091FD700DD47E9 /* FeedUIIntegrationTests+TestHelpers.swift */; }; - 5B6992F02D09223300DD47E9 /* FeedImageCell+TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992EF2D09223300DD47E9 /* FeedImageCell+TestHelpers.swift */; }; - 5B6992F22D0922FD00DD47E9 /* FeedUIIntegrationTests+LoaderSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992F12D0922FD00DD47E9 /* FeedUIIntegrationTests+LoaderSpy.swift */; }; - 5B6992F42D0923F500DD47E9 /* UIRefreshControlSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992F32D0923F500DD47E9 /* UIRefreshControlSpy.swift */; }; - 5B6992F62D09313600DD47E9 /* FeedUIIntegrationTests+Assertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B6992F52D09313600DD47E9 /* FeedUIIntegrationTests+Assertions.swift */; }; 5B74FD1A2D649D0E007478DC /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B74FD192D649D0D007478DC /* ErrorView.swift */; }; 5B74FD1F2D64A821007478DC /* UIRefreshControl+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B74FD1E2D64A821007478DC /* UIRefreshControl+Helpers.swift */; }; 5B74FD222D6A5F39007478DC /* FeedPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B74FD212D6A5F39007478DC /* FeedPresenterTests.swift */; }; @@ -79,12 +68,6 @@ 5B8AB3662D51AB6200CDDDEB /* Feed.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5B8AB3652D51AB6200CDDDEB /* Feed.xcassets */; }; 5B8AB3682D52D73200CDDDEB /* UITableView+Dequeueing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8AB3672D52D73200CDDDEB /* UITableView+Dequeueing.swift */; }; 5B8AB36A2D52DC3600CDDDEB /* UIImageView+Animations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8AB3692D52DC3600CDDDEB /* UIImageView+Animations.swift */; }; - 5B8AB36E2D5EC72400CDDDEB /* FeedUIIntegrationTests+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8AB36D2D5EC72400CDDDEB /* FeedUIIntegrationTests+Localization.swift */; }; - 5B8AB3772D5EEAD100CDDDEB /* MainQueueDispatchDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8AB3762D5EEAD100CDDDEB /* MainQueueDispatchDecorator.swift */; }; - 5B8AB3792D5EEB9E00CDDDEB /* WeakRefVirtualProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8AB3782D5EEB9E00CDDDEB /* WeakRefVirtualProxy.swift */; }; - 5B8AB37B2D5EEC8000CDDDEB /* FeedImageDataLoaderPresentationAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8AB37A2D5EEC8000CDDDEB /* FeedImageDataLoaderPresentationAdapter.swift */; }; - 5B8AB37D2D5EED1C00CDDDEB /* FeedLoaderPresentationAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8AB37C2D5EED1C00CDDDEB /* FeedLoaderPresentationAdapter.swift */; }; - 5B8AB37F2D5EED8E00CDDDEB /* FeedViewAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8AB37E2D5EED8E00CDDDEB /* FeedViewAdapter.swift */; }; 5B8BB9992C02719F00D40D42 /* XCTestCase+MemoryLeakTracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8BB9982C02719F00D40D42 /* XCTestCase+MemoryLeakTracking.swift */; }; 5B8BD18F2C3798D400CCA870 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8BD18E2C3798D400CCA870 /* CacheFeedUseCaseTests.swift */; }; 5BA598B42CE18F9F007B1795 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B107DF82BF5BB2100927709 /* EssentialFeed.framework */; }; @@ -173,7 +156,6 @@ 5B034B422CA3A0C800FB65F8 /* FeedCacheTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCacheTestHelpers.swift; sourceTree = ""; }; 5B034B442CA3A1A100FB65F8 /* SharedTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedTestHelpers.swift; sourceTree = ""; }; 5B06826D2D0A5E59009749F3 /* FeedImageCellController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageCellController.swift; sourceTree = ""; }; - 5B0682702D0A8E24009749F3 /* FeedUIComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedUIComposer.swift; sourceTree = ""; }; 5B0E220A2BFE2FEA009FC3EB /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = ""; }; 5B0E220C2BFE3135009FC3EB /* FeedItemsMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemsMapper.swift; sourceTree = ""; }; 5B107DF82BF5BB2100927709 /* EssentialFeed.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = EssentialFeed.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -190,19 +172,9 @@ 5B4BAE7D2CFBA0EE00CE079A /* EssentialFeediOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeediOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5B4BAE8F2CFBA12A00CE079A /* EssentialFeediOS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EssentialFeediOS.h; sourceTree = ""; }; 5B4BAE922CFBA29600CE079A /* EssentialFeediOS.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = EssentialFeediOS.xctestplan; path = EssentialFeediOSTests/EssentialFeediOS.xctestplan; sourceTree = SOURCE_ROOT; }; - 5B69928E2CFE811200DD47E9 /* FeedUIIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedUIIntegrationTests.swift; sourceTree = ""; }; 5B6992AC2D012C7200DD47E9 /* FeedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedViewController.swift; sourceTree = ""; }; 5B6992D62D03F23700DD47E9 /* FeedImageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageCell.swift; sourceTree = ""; }; 5B6992D82D0662B200DD47E9 /* UIView+Shimmering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Shimmering.swift"; sourceTree = ""; }; - 5B6992E22D091BED00DD47E9 /* UIImage+TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+TestHelpers.swift"; sourceTree = ""; }; - 5B6992E42D091CA600DD47E9 /* UIRefreshControl+TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIRefreshControl+TestHelpers.swift"; sourceTree = ""; }; - 5B6992E62D091D5000DD47E9 /* UIButton+TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+TestHelpers.swift"; sourceTree = ""; }; - 5B6992E82D091DF400DD47E9 /* UIControl+TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIControl+TestHelpers.swift"; sourceTree = ""; }; - 5B6992ED2D091FD700DD47E9 /* FeedUIIntegrationTests+TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedUIIntegrationTests+TestHelpers.swift"; sourceTree = ""; }; - 5B6992EF2D09223300DD47E9 /* FeedImageCell+TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedImageCell+TestHelpers.swift"; sourceTree = ""; }; - 5B6992F12D0922FD00DD47E9 /* FeedUIIntegrationTests+LoaderSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedUIIntegrationTests+LoaderSpy.swift"; sourceTree = ""; }; - 5B6992F32D0923F500DD47E9 /* UIRefreshControlSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIRefreshControlSpy.swift; sourceTree = ""; }; - 5B6992F52D09313600DD47E9 /* FeedUIIntegrationTests+Assertions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedUIIntegrationTests+Assertions.swift"; sourceTree = ""; }; 5B6992FA2D09353200DD47E9 /* FeedImageDataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageDataLoader.swift; sourceTree = ""; }; 5B74FD192D649D0D007478DC /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; 5B74FD1E2D64A821007478DC /* UIRefreshControl+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIRefreshControl+Helpers.swift"; sourceTree = ""; }; @@ -232,13 +204,7 @@ 5B8AB3652D51AB6200CDDDEB /* Feed.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Feed.xcassets; sourceTree = ""; }; 5B8AB3672D52D73200CDDDEB /* UITableView+Dequeueing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+Dequeueing.swift"; sourceTree = ""; }; 5B8AB3692D52DC3600CDDDEB /* UIImageView+Animations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImageView+Animations.swift"; sourceTree = ""; }; - 5B8AB36D2D5EC72400CDDDEB /* FeedUIIntegrationTests+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedUIIntegrationTests+Localization.swift"; sourceTree = ""; }; 5B8AB3732D5ECCBF00CDDDEB /* FeedLocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLocalizationTests.swift; sourceTree = ""; }; - 5B8AB3762D5EEAD100CDDDEB /* MainQueueDispatchDecorator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainQueueDispatchDecorator.swift; sourceTree = ""; }; - 5B8AB3782D5EEB9E00CDDDEB /* WeakRefVirtualProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakRefVirtualProxy.swift; sourceTree = ""; }; - 5B8AB37A2D5EEC8000CDDDEB /* FeedImageDataLoaderPresentationAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageDataLoaderPresentationAdapter.swift; sourceTree = ""; }; - 5B8AB37C2D5EED1C00CDDDEB /* FeedLoaderPresentationAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLoaderPresentationAdapter.swift; sourceTree = ""; }; - 5B8AB37E2D5EED8E00CDDDEB /* FeedViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedViewAdapter.swift; sourceTree = ""; }; 5B8BB9982C02719F00D40D42 /* XCTestCase+MemoryLeakTracking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+MemoryLeakTracking.swift"; sourceTree = ""; }; 5B8BD15D2C06D36300CCA870 /* CI_macOS.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = CI_macOS.xctestplan; sourceTree = ""; }; 5B8BD18E2C3798D400CCA870 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; @@ -340,19 +306,6 @@ path = Helpers; sourceTree = ""; }; - 5B06826F2D0A8E03009749F3 /* Composers */ = { - isa = PBXGroup; - children = ( - 5B0682702D0A8E24009749F3 /* FeedUIComposer.swift */, - 5B8AB3762D5EEAD100CDDDEB /* MainQueueDispatchDecorator.swift */, - 5B8AB3782D5EEB9E00CDDDEB /* WeakRefVirtualProxy.swift */, - 5B8AB37A2D5EEC8000CDDDEB /* FeedImageDataLoaderPresentationAdapter.swift */, - 5B8AB37C2D5EED1C00CDDDEB /* FeedLoaderPresentationAdapter.swift */, - 5B8AB37E2D5EED8E00CDDDEB /* FeedViewAdapter.swift */, - ); - path = Composers; - sourceTree = ""; - }; 5B0E220E2BFE404F009FC3EB /* Feed API */ = { isa = PBXGroup; children = ( @@ -441,7 +394,6 @@ isa = PBXGroup; children = ( 5B4BAE922CFBA29600CE079A /* EssentialFeediOS.xctestplan */, - 5B6992F82D0933C300DD47E9 /* Feed UI */, ); path = EssentialFeediOSTests; sourceTree = ""; @@ -462,23 +414,6 @@ name = Frameworks; sourceTree = ""; }; - 5B6992E12D091B8800DD47E9 /* Helpers */ = { - isa = PBXGroup; - children = ( - 5B6992E22D091BED00DD47E9 /* UIImage+TestHelpers.swift */, - 5B6992E42D091CA600DD47E9 /* UIRefreshControl+TestHelpers.swift */, - 5B6992E62D091D5000DD47E9 /* UIButton+TestHelpers.swift */, - 5B6992E82D091DF400DD47E9 /* UIControl+TestHelpers.swift */, - 5B6992F32D0923F500DD47E9 /* UIRefreshControlSpy.swift */, - 5B6992EF2D09223300DD47E9 /* FeedImageCell+TestHelpers.swift */, - 5B6992ED2D091FD700DD47E9 /* FeedUIIntegrationTests+TestHelpers.swift */, - 5B6992F12D0922FD00DD47E9 /* FeedUIIntegrationTests+LoaderSpy.swift */, - 5B6992F52D09313600DD47E9 /* FeedUIIntegrationTests+Assertions.swift */, - 5B8AB36D2D5EC72400CDDDEB /* FeedUIIntegrationTests+Localization.swift */, - ); - path = Helpers; - sourceTree = ""; - }; 5B6992EB2D091F0A00DD47E9 /* Controllers */ = { isa = PBXGroup; children = ( @@ -503,22 +438,12 @@ 5B6992F72D0932FA00DD47E9 /* Feed UI */ = { isa = PBXGroup; children = ( - 5B06826F2D0A8E03009749F3 /* Composers */, 5B6992EB2D091F0A00DD47E9 /* Controllers */, 5B6992EC2D091F2100DD47E9 /* Views */, ); path = "Feed UI"; sourceTree = ""; }; - 5B6992F82D0933C300DD47E9 /* Feed UI */ = { - isa = PBXGroup; - children = ( - 5B6992E12D091B8800DD47E9 /* Helpers */, - 5B69928E2CFE811200DD47E9 /* FeedUIIntegrationTests.swift */, - ); - path = "Feed UI"; - sourceTree = ""; - }; 5B74FD1B2D64A6DE007478DC /* Helpers */ = { isa = PBXGroup; children = ( @@ -986,16 +911,10 @@ 5B74FD1F2D64A821007478DC /* UIRefreshControl+Helpers.swift in Sources */, 5B8AB3682D52D73200CDDDEB /* UITableView+Dequeueing.swift in Sources */, 5B6992D92D0662B200DD47E9 /* UIView+Shimmering.swift in Sources */, - 5B8AB37D2D5EED1C00CDDDEB /* FeedLoaderPresentationAdapter.swift in Sources */, - 5B8AB37B2D5EEC8000CDDDEB /* FeedImageDataLoaderPresentationAdapter.swift in Sources */, - 5B8AB3792D5EEB9E00CDDDEB /* WeakRefVirtualProxy.swift in Sources */, 5B8AB36A2D52DC3600CDDDEB /* UIImageView+Animations.swift in Sources */, - 5B8AB3772D5EEAD100CDDDEB /* MainQueueDispatchDecorator.swift in Sources */, 5B6992AD2D012C7200DD47E9 /* FeedViewController.swift in Sources */, 5B6992D72D03F23700DD47E9 /* FeedImageCell.swift in Sources */, 5B06826E2D0A5E59009749F3 /* FeedImageCellController.swift in Sources */, - 5B8AB37F2D5EED8E00CDDDEB /* FeedViewAdapter.swift in Sources */, - 5B0682712D0A8E24009749F3 /* FeedUIComposer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1003,18 +922,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5B6992F42D0923F500DD47E9 /* UIRefreshControlSpy.swift in Sources */, - 5B69928F2CFE811200DD47E9 /* FeedUIIntegrationTests.swift in Sources */, - 5B6992E72D091D5000DD47E9 /* UIButton+TestHelpers.swift in Sources */, - 5B6992E52D091CA600DD47E9 /* UIRefreshControl+TestHelpers.swift in Sources */, - 5B6992E92D091DF400DD47E9 /* UIControl+TestHelpers.swift in Sources */, - 5B6992F22D0922FD00DD47E9 /* FeedUIIntegrationTests+LoaderSpy.swift in Sources */, - 5B6992EE2D091FD700DD47E9 /* FeedUIIntegrationTests+TestHelpers.swift in Sources */, 5B6992972CFE8D8B00DD47E9 /* XCTestCase+MemoryLeakTracking.swift in Sources */, - 5B6992E32D091BED00DD47E9 /* UIImage+TestHelpers.swift in Sources */, - 5B6992F62D09313600DD47E9 /* FeedUIIntegrationTests+Assertions.swift in Sources */, - 5B6992F02D09223300DD47E9 /* FeedImageCell+TestHelpers.swift in Sources */, - 5B8AB36E2D5EC72400CDDDEB /* FeedUIIntegrationTests+Localization.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift index 2a271a7..c18cb59 100644 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift @@ -6,16 +6,16 @@ import UIKit import EssentialFeed -protocol FeedImageCellControllerDelegate { +public protocol FeedImageCellControllerDelegate { func didRequestImage() func didCancelImageRequest() } -final class FeedImageCellController: FeedImageView { +public final class FeedImageCellController: FeedImageView { private let delegate: FeedImageCellControllerDelegate private var cell: FeedImageCell? - init(delegate: FeedImageCellControllerDelegate) { + public init(delegate: FeedImageCellControllerDelegate) { self.delegate = delegate } @@ -37,7 +37,7 @@ final class FeedImageCellController: FeedImageView { delegate.didCancelImageRequest() } - func display(_ viewModel: FeedImageViewModel) { + public func display(_ viewModel: FeedImageViewModel) { cell?.locationContainer.isHidden = !viewModel.hasLocation cell?.locationLabel.text = viewModel.location cell?.descriptionLabel.text = viewModel.description diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift index 1698d4f..a0f61c6 100644 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift @@ -6,19 +6,20 @@ import UIKit import EssentialFeed -protocol FeedViewControllerDelegate { +public protocol FeedViewControllerDelegate { func didRequestFeedRefresh() } public final class FeedViewController: UITableViewController, UITableViewDataSourcePrefetching, FeedLoadingView, FeedErrorView { - var delegate: FeedViewControllerDelegate? @IBOutlet private(set) public var errorView: ErrorView? - var tableModel = [FeedImageCellController]() { + private var tableModel = [FeedImageCellController]() { didSet { tableView.reloadData() } } private var onViewIsAppearing: ((FeedViewController) -> Void)? + + public var delegate: FeedViewControllerDelegate? public override func viewDidLoad() { super.viewDidLoad() @@ -35,6 +36,10 @@ public final class FeedViewController: UITableViewController, UITableViewDataSou delegate?.didRequestFeedRefresh() } + public func display(_ cellControllers: [FeedImageCellController]) { + tableModel = cellControllers + } + public func display(_ viewModel: FeedLoadingViewModel) { refreshControl?.update(isRefreshing: viewModel.isLoading) } From 3c32abcd0370c1d862a229b96072d9625c815b52 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 5 Mar 2025 22:36:12 -0300 Subject: [PATCH 03/13] Displays remote feed on launch when customer has connectivity --- EssentialApp/EssentialApp/SceneDelegate.swift | 21 ++- .../FeedAcceptanceTests.swift | 127 ++++++++++++++++++ .../FeedViewController+TestHelpers.swift | 4 + 3 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index c9a437e..6714260 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -15,6 +15,20 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { .defaultDirectoryURL() .appendingPathComponent("feed-store.sqlite") + private lazy var httpClient: HTTPClient = { + URLSessionHTTPClient(session: URLSession(configuration: .ephemeral)) + }() + + private lazy var store: FeedStore & FeedImageDataStore = { + try! CoreDataFeedStore(storeURL: localStoreURL) + }() + + convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore) { + self.init() + self.httpClient = httpClient + self.store = store + } + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let _ = (scene as? UIWindowScene) else { return } @@ -28,9 +42,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let remoteFeedLoader = RemoteFeedLoader(url: remoteURL, client: remoteClient) let remoteImageLoader = RemoteFeedImageDataLoader(client: remoteClient) - let localStore = try! CoreDataFeedStore(storeURL: localStoreURL) - let localFeedLoader = LocalFeedLoader(store: localStore, currentDate: Date.init) - let localImageLoader = LocalFeedImageDataLoader(store: localStore) + let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init) + let localImageLoader = LocalFeedImageDataLoader(store: store) window?.rootViewController = UINavigationController( rootViewController: FeedUIComposer.feedComposedWith( @@ -47,6 +60,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } func makeRemoteClient() -> HTTPClient { - return URLSessionHTTPClient(session: URLSession(configuration: .ephemeral)) + return httpClient } } diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift new file mode 100644 index 0000000..0eb5f20 --- /dev/null +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -0,0 +1,127 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeed +import EssentialFeediOS +@testable import EssentialApp + +class FeedAcceptanceTests: XCTestCase { + + func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() { + let feed = launch(httpClient: .online(response), store: .empty) + feed.simulateAppearance() + + XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 2) + XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData()) + XCTAssertEqual(feed.renderedFeedImageData(at: 1), makeImageData()) + } + + func test_onLaunch_displaysCachedRemoteFeedWhenCustomerHasNoConnectivity() { + + } + + func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() { + + } + + // MARK: - Helpers + + private func launch( + httpClient: HTTPClientStub = .offline, + store: InMemoryFeedStore = .empty + ) -> FeedViewController { + let sut = SceneDelegate(httpClient: httpClient, store: store) + sut.window = UIWindow() + sut.configureWindow() + + let nav = sut.window?.rootViewController as? UINavigationController + return nav?.topViewController as! FeedViewController + } + + private class HTTPClientStub: HTTPClient { + private class Task: HTTPClientTask { + func cancel() {} + } + + private let stub: (URL) -> HTTPClient.Result + + init(stub: @escaping (URL) -> HTTPClient.Result) { + self.stub = stub + } + + func get(from url: URL, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask { + completion(stub(url)) + return Task() + } + + static var offline: HTTPClientStub { + HTTPClientStub(stub: { _ in .failure(NSError(domain: "offline", code: 0)) }) + } + + static func online(_ stub: @escaping (URL) -> (Data, HTTPURLResponse)) -> HTTPClientStub { + HTTPClientStub { url in .success(stub(url)) } + } + } + + private class InMemoryFeedStore: FeedStore, FeedImageDataStore { + private var feedCache: CachedFeed? + private var feedImageDataCache: [URL: Data] = [:] + + func deleteCachedFeed(completion: @escaping FeedStore.DeletionCompletion) { + feedCache = nil + completion(.success(())) + } + + func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping FeedStore.InsertionCompletion) { + feedCache = CachedFeed(feed: feed, timestamp: timestamp) + completion(.success(())) + } + + func retrieve(completion: @escaping FeedStore.RetrievalCompletion) { + completion(.success(feedCache)) + } + + func insert(_ data: Data, for url: URL, completion: @escaping (FeedImageDataStore.InsertionResult) -> Void) { + feedImageDataCache[url] = data + completion(.success(())) + } + + func retrieve(dataForURL url: URL, completion: @escaping (FeedImageDataStore.RetrievalResult) -> Void) { + completion(.success(feedImageDataCache[url])) + } + + static var empty: InMemoryFeedStore { + InMemoryFeedStore() + } + } + + private func response(for url: URL) -> (Data, HTTPURLResponse) { + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (makeData(for: url), response) + } + + private func makeData(for url: URL) -> Data { + switch url.absoluteString { + case "http://image.com": + return makeImageData() + + default: + return makeFeedData() + } + } + + private func makeImageData() -> Data { + return UIImage.make(withColor: .red).pngData()! + } + + private func makeFeedData() -> Data { + return try! JSONSerialization.data(withJSONObject: ["items": [ + ["id": UUID().uuidString, "image": "http://image.com"], + ["id": UUID().uuidString, "image": "http://image.com"] + ]]) + } + +} diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift index f326786..2081b33 100644 --- a/EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift +++ b/EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift @@ -67,6 +67,10 @@ extension FeedViewController { ds?.tableView?(tableView, cancelPrefetchingForRowsAt: [index]) } + func renderedFeedImageData(at index: Int) -> Data? { + return simulateFeedImageViewVisible(at: index)?.renderedImage + } + var errorMessage: String? { return errorView?.message } From 04e05d0b00a2af87e50c1337cf89d52f3377ccf5 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 5 Mar 2025 22:39:09 -0300 Subject: [PATCH 04/13] Displays cached feed on launch when customer has no connectivity --- .../EssentialAppTests/FeedAcceptanceTests.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index 0eb5f20..44f9cad 100644 --- a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -20,7 +20,18 @@ class FeedAcceptanceTests: XCTestCase { } func test_onLaunch_displaysCachedRemoteFeedWhenCustomerHasNoConnectivity() { + let sharedStore = InMemoryFeedStore.empty + let onlineFeed = launch(httpClient: .online(response), store: sharedStore) + onlineFeed.simulateAppearance() + onlineFeed.simulateFeedImageViewVisible(at: 0) + onlineFeed.simulateFeedImageViewVisible(at: 1) + let offlineFeed = launch(httpClient: .offline, store: sharedStore) + offlineFeed.simulateAppearance() + + XCTAssertEqual(offlineFeed.numberOfRenderedFeedImageViews(), 2) + XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 0), makeImageData()) + XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 1), makeImageData()) } func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() { From 793a61c31942520ee377d91dcd50f67056d6061e Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 5 Mar 2025 22:40:32 -0300 Subject: [PATCH 05/13] Displays empty feed on launch when customer has no connectivity and no cache --- EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index 44f9cad..576c1bb 100644 --- a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -35,7 +35,10 @@ class FeedAcceptanceTests: XCTestCase { } func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() { - + let feed = launch(httpClient: .offline, store: .empty) + feed.simulateAppearance() + + XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 0) } // MARK: - Helpers From 4cdc1842ca93247f3e732caa621489e8d2cc445e Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 5 Mar 2025 23:03:16 -0300 Subject: [PATCH 06/13] Check number of cells before fetching cell at index to avoid out of bounds exception --- .../Helpers/FeedViewController+TestHelpers.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift index 2081b33..f2d37fc 100644 --- a/EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift +++ b/EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift @@ -84,6 +84,9 @@ extension FeedViewController { } func feedImageView(at row: Int) -> UITableViewCell? { + guard numberOfRenderedFeedImageViews() > row else { + return nil + } let ds = tableView.dataSource let index = IndexPath(row: row, section: feedImagesSection) return ds?.tableView(tableView, cellForRowAt: index) From eb039c85e7d9ae5c2823e754246440a9c133f6ea Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 5 Mar 2025 23:07:14 -0300 Subject: [PATCH 07/13] Remove EssentialAppUIAcceptanceTests in favor of faster and more precise integration tests --- EssentialApp/CI_iOS.xctestplan | 7 -- .../EssentialApp.xcodeproj/project.pbxproj | 114 ------------------ .../contents.xcworkspacedata | 3 - .../EssentialAppUIAcceptanceTests.xcscheme | 60 --------- .../EssentialAppUIAcceptanceTests.swift | 47 -------- .../EssentialAppUIAcceptanceTests.xctestplan | 33 ----- 6 files changed, 264 deletions(-) delete mode 100644 EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/EssentialAppUIAcceptanceTests.xcscheme delete mode 100644 EssentialApp/EssentialAppUIAcceptanceTests/EssentialAppUIAcceptanceTests.swift delete mode 100644 EssentialApp/EssentialAppUIAcceptanceTests/EssentialAppUIAcceptanceTests.xctestplan diff --git a/EssentialApp/CI_iOS.xctestplan b/EssentialApp/CI_iOS.xctestplan index b171d7e..44b44e4 100644 --- a/EssentialApp/CI_iOS.xctestplan +++ b/EssentialApp/CI_iOS.xctestplan @@ -70,13 +70,6 @@ "identifier" : "5BDE3C862D6D12BA005D520D", "name" : "EssentialAppTests" } - }, - { - "target" : { - "containerPath" : "container:EssentialApp.xcodeproj", - "identifier" : "5BBDA0822D72983C00D68DF0", - "name" : "EssentialAppUIAcceptanceTests" - } } ], "version" : 1 diff --git a/EssentialApp/EssentialApp.xcodeproj/project.pbxproj b/EssentialApp/EssentialApp.xcodeproj/project.pbxproj index 4540d98..6d5359e 100644 --- a/EssentialApp/EssentialApp.xcodeproj/project.pbxproj +++ b/EssentialApp/EssentialApp.xcodeproj/project.pbxproj @@ -15,13 +15,6 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - 5BBDA0892D72983C00D68DF0 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 5BDE3C692D6D12B8005D520D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 5BDE3C702D6D12B8005D520D; - remoteInfo = EssentialApp; - }; 5BDE3C882D6D12BA005D520D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5BDE3C692D6D12B8005D520D /* Project object */; @@ -48,7 +41,6 @@ /* Begin PBXFileReference section */ 5BBD9FDF2D6EA59800D68DF0 /* CI_iOS.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = CI_iOS.xctestplan; sourceTree = ""; }; - 5BBDA0832D72983C00D68DF0 /* EssentialAppUIAcceptanceTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialAppUIAcceptanceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5BDE3C712D6D12B8005D520D /* EssentialApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EssentialApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5BDE3C872D6D12BA005D520D /* EssentialAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5BDE3CDF2D6D186D005D520D /* EssentialFeed.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = EssentialFeed.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -67,11 +59,6 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 5BBDA0842D72983C00D68DF0 /* EssentialAppUIAcceptanceTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = EssentialAppUIAcceptanceTests; - sourceTree = ""; - }; 5BDE3C732D6D12B8005D520D /* EssentialApp */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -88,13 +75,6 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ - 5BBDA0802D72983C00D68DF0 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 5BDE3C6E2D6D12B8005D520D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -121,7 +101,6 @@ 5BDE3D0C2D6D6092005D520D /* EssentialApp.xctestplan */, 5BDE3C732D6D12B8005D520D /* EssentialApp */, 5BDE3C8A2D6D12BA005D520D /* EssentialAppTests */, - 5BBDA0842D72983C00D68DF0 /* EssentialAppUIAcceptanceTests */, 5BDE3CDE2D6D186D005D520D /* Frameworks */, 5BDE3C722D6D12B8005D520D /* Products */, ); @@ -132,7 +111,6 @@ children = ( 5BDE3C712D6D12B8005D520D /* EssentialApp.app */, 5BDE3C872D6D12BA005D520D /* EssentialAppTests.xctest */, - 5BBDA0832D72983C00D68DF0 /* EssentialAppUIAcceptanceTests.xctest */, ); name = Products; sourceTree = ""; @@ -149,29 +127,6 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 5BBDA0822D72983C00D68DF0 /* EssentialAppUIAcceptanceTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 5BBDA08B2D72983C00D68DF0 /* Build configuration list for PBXNativeTarget "EssentialAppUIAcceptanceTests" */; - buildPhases = ( - 5BBDA07F2D72983C00D68DF0 /* Sources */, - 5BBDA0802D72983C00D68DF0 /* Frameworks */, - 5BBDA0812D72983C00D68DF0 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 5BBDA08A2D72983C00D68DF0 /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - 5BBDA0842D72983C00D68DF0 /* EssentialAppUIAcceptanceTests */, - ); - name = EssentialAppUIAcceptanceTests; - packageProductDependencies = ( - ); - productName = EssentialAppUIAcceptanceTests; - productReference = 5BBDA0832D72983C00D68DF0 /* EssentialAppUIAcceptanceTests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; 5BDE3C702D6D12B8005D520D /* EssentialApp */ = { isa = PBXNativeTarget; buildConfigurationList = 5BDE3C9A2D6D12BA005D520D /* Build configuration list for PBXNativeTarget "EssentialApp" */; @@ -228,10 +183,6 @@ LastSwiftUpdateCheck = 1620; LastUpgradeCheck = 1620; TargetAttributes = { - 5BBDA0822D72983C00D68DF0 = { - CreatedOnToolsVersion = 16.2; - TestTargetID = 5BDE3C702D6D12B8005D520D; - }; 5BDE3C702D6D12B8005D520D = { CreatedOnToolsVersion = 16.2; }; @@ -258,19 +209,11 @@ targets = ( 5BDE3C702D6D12B8005D520D /* EssentialApp */, 5BDE3C862D6D12BA005D520D /* EssentialAppTests */, - 5BBDA0822D72983C00D68DF0 /* EssentialAppUIAcceptanceTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 5BBDA0812D72983C00D68DF0 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 5BDE3C6F2D6D12B8005D520D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -289,13 +232,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 5BBDA07F2D72983C00D68DF0 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 5BDE3C6D2D6D12B8005D520D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -313,11 +249,6 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 5BBDA08A2D72983C00D68DF0 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 5BDE3C702D6D12B8005D520D /* EssentialApp */; - targetProxy = 5BBDA0892D72983C00D68DF0 /* PBXContainerItemProxy */; - }; 5BDE3C892D6D12BA005D520D /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5BDE3C702D6D12B8005D520D /* EssentialApp */; @@ -326,42 +257,6 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - 5BBDA08C2D72983C00D68DF0 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 4VK5P5723H; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = portocode.EssentialAppUIAcceptanceTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = EssentialApp; - }; - name = Debug; - }; - 5BBDA08D2D72983C00D68DF0 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 4VK5P5723H; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = portocode.EssentialAppUIAcceptanceTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = EssentialApp; - }; - name = Release; - }; 5BDE3C9B2D6D12BA005D520D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -583,15 +478,6 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 5BBDA08B2D72983C00D68DF0 /* Build configuration list for PBXNativeTarget "EssentialAppUIAcceptanceTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 5BBDA08C2D72983C00D68DF0 /* Debug */, - 5BBDA08D2D72983C00D68DF0 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 5BDE3C6C2D6D12B8005D520D /* Build configuration list for PBXProject "EssentialApp" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/EssentialApp/EssentialApp.xcworkspace/contents.xcworkspacedata b/EssentialApp/EssentialApp.xcworkspace/contents.xcworkspacedata index 3a858a6..75c7596 100644 --- a/EssentialApp/EssentialApp.xcworkspace/contents.xcworkspacedata +++ b/EssentialApp/EssentialApp.xcworkspace/contents.xcworkspacedata @@ -7,7 +7,4 @@ - - diff --git a/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/EssentialAppUIAcceptanceTests.xcscheme b/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/EssentialAppUIAcceptanceTests.xcscheme deleted file mode 100644 index 5a4bb6a..0000000 --- a/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/EssentialAppUIAcceptanceTests.xcscheme +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/EssentialApp/EssentialAppUIAcceptanceTests/EssentialAppUIAcceptanceTests.swift b/EssentialApp/EssentialAppUIAcceptanceTests/EssentialAppUIAcceptanceTests.swift deleted file mode 100644 index 658afb0..0000000 --- a/EssentialApp/EssentialAppUIAcceptanceTests/EssentialAppUIAcceptanceTests.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2025 PortoCode. All Rights Reserved. -// - -import XCTest - -final class EssentialAppUIAcceptanceTests: XCTestCase { - - func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() { - let app = XCUIApplication() - app.launchArguments = ["-reset", "-connectivity", "online"] - app.launch() - - let feedCells = app.cells.matching(identifier: "feed-image-cell") - XCTAssertEqual(feedCells.count, 2) - - let firstImage = app.images.matching(identifier: "feed-image-view").firstMatch - XCTAssertTrue(firstImage.exists) - } - - func test_onLaunch_displaysCachedRemoteFeedWhenCustomerHasNoConnectivity() { - let onlineApp = XCUIApplication() - onlineApp.launchArguments = ["-reset", "-connectivity", "online"] - onlineApp.launch() - - let offlineApp = XCUIApplication() - offlineApp.launchArguments = ["-connectivity", "offline"] - offlineApp.launch() - - let cachedFeedCells = offlineApp.cells.matching(identifier: "feed-image-cell") - XCTAssertEqual(cachedFeedCells.count, 2) - - let firstCachedImage = offlineApp.images.matching(identifier: "feed-image-view").firstMatch - XCTAssertTrue(firstCachedImage.exists) - } - - func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() { - let app = XCUIApplication() - app.launchArguments = ["-reset", "-connectivity", "offline"] - app.launch() - - let feedCells = app.cells.matching(identifier: "feed-image-cell") - XCTAssertEqual(feedCells.count, 0) - } - -} diff --git a/EssentialApp/EssentialAppUIAcceptanceTests/EssentialAppUIAcceptanceTests.xctestplan b/EssentialApp/EssentialAppUIAcceptanceTests/EssentialAppUIAcceptanceTests.xctestplan deleted file mode 100644 index 794f4e9..0000000 --- a/EssentialApp/EssentialAppUIAcceptanceTests/EssentialAppUIAcceptanceTests.xctestplan +++ /dev/null @@ -1,33 +0,0 @@ -{ - "configurations" : [ - { - "id" : "BEB595B4-9364-4FB0-9A36-B6EAFD0D066A", - "name" : "Test Scheme Action", - "options" : { - - } - } - ], - "defaultOptions" : { - "codeCoverage" : { - "targets" : [ - { - "containerPath" : "container:EssentialApp.xcodeproj", - "identifier" : "5BDE3C702D6D12B8005D520D", - "name" : "EssentialApp" - } - ] - }, - "testExecutionOrdering" : "random" - }, - "testTargets" : [ - { - "target" : { - "containerPath" : "container:EssentialApp.xcodeproj", - "identifier" : "5BBDA0822D72983C00D68DF0", - "name" : "EssentialAppUIAcceptanceTests" - } - } - ], - "version" : 1 -} From 4fb91c4fa839ebeb5bc43d8e809bb14a87248f66 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 5 Mar 2025 23:14:49 -0300 Subject: [PATCH 08/13] Remove unused debugging code that was previously used during UI tests --- EssentialApp/EssentialApp/AppDelegate.swift | 14 +--- .../EssentialApp/DebuggingSceneDelegate.swift | 83 ------------------- EssentialApp/EssentialApp/SceneDelegate.swift | 18 ++-- 3 files changed, 8 insertions(+), 107 deletions(-) delete mode 100644 EssentialApp/EssentialApp/DebuggingSceneDelegate.swift diff --git a/EssentialApp/EssentialApp/AppDelegate.swift b/EssentialApp/EssentialApp/AppDelegate.swift index cbb2960..cea9a97 100644 --- a/EssentialApp/EssentialApp/AppDelegate.swift +++ b/EssentialApp/EssentialApp/AppDelegate.swift @@ -8,8 +8,6 @@ import UIKit @main class AppDelegate: UIResponder, UIApplicationDelegate { - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. return true @@ -20,13 +18,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { // Called when a new scene session is being created. // Use this method to select a configuration to create the new scene with. - let configuration = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) - - #if DEBUG - configuration.delegateClass = DebuggingSceneDelegate.self - #endif - - return configuration + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { @@ -34,7 +26,5 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } - - + } - diff --git a/EssentialApp/EssentialApp/DebuggingSceneDelegate.swift b/EssentialApp/EssentialApp/DebuggingSceneDelegate.swift deleted file mode 100644 index cee4daf..0000000 --- a/EssentialApp/EssentialApp/DebuggingSceneDelegate.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2025 PortoCode. All Rights Reserved. -// - -#if DEBUG -import UIKit -import EssentialFeed - -class DebuggingSceneDelegate: SceneDelegate { - override func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - guard let _ = (scene as? UIWindowScene) else { return } - - if CommandLine.arguments.contains("-reset") { - try? FileManager.default.removeItem(at: localStoreURL) - } - - super.scene(scene, willConnectTo: session, options: connectionOptions) - } - - override func makeRemoteClient() -> HTTPClient { - if let connectivity = UserDefaults.standard.string(forKey: "connectivity") { - return DebuggingHTTPClient(connectivity: connectivity) - } - return super.makeRemoteClient() - } -} - -private class DebuggingHTTPClient: HTTPClient { - private class Task: HTTPClientTask { - func cancel() {} - } - - private let connectivity: String - - init(connectivity: String) { - self.connectivity = connectivity - } - - func get(from url: URL, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask { - switch connectivity { - case "online": - completion(.success(makeSuccessfulResponse(for: url))) - default: - completion(.failure(NSError(domain: "offline", code: 0))) - } - return Task() - } - - private func makeSuccessfulResponse(for url: URL) -> (Data, HTTPURLResponse) { - let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! - return (makeData(for: url), response) - } - - private func makeData(for url: URL) -> Data { - switch url.absoluteString { - case "http://image.com": - return makeImageData() - - default: - return makeFeedData() - } - } - - private func makeImageData() -> Data { - let rect = CGRect(x: 0, y: 0, width: 1, height: 1) - UIGraphicsBeginImageContext(rect.size) - let context = UIGraphicsGetCurrentContext()! - context.setFillColor(UIColor.red.cgColor) - context.fill(rect) - let img = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - return img!.pngData()! - } - - private func makeFeedData() -> Data { - return try! JSONSerialization.data(withJSONObject: ["items": [ - ["id": UUID().uuidString, "image": "http://image.com"], - ["id": UUID().uuidString, "image": "http://image.com"] - ]]) - } -} -#endif diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index 6714260..a334a3a 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -11,16 +11,15 @@ import EssentialFeediOS class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - let localStoreURL = NSPersistentContainer - .defaultDirectoryURL() - .appendingPathComponent("feed-store.sqlite") - private lazy var httpClient: HTTPClient = { URLSessionHTTPClient(session: URLSession(configuration: .ephemeral)) }() private lazy var store: FeedStore & FeedImageDataStore = { - try! CoreDataFeedStore(storeURL: localStoreURL) + try! CoreDataFeedStore( + storeURL: NSPersistentContainer + .defaultDirectoryURL() + .appendingPathComponent("feed-store.sqlite")) }() convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore) { @@ -38,9 +37,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func configureWindow() { let remoteURL = URL(string: "https://ile-api.essentialdeveloper.com/essential-feed/v1/feed")! - let remoteClient = makeRemoteClient() - let remoteFeedLoader = RemoteFeedLoader(url: remoteURL, client: remoteClient) - let remoteImageLoader = RemoteFeedImageDataLoader(client: remoteClient) + let remoteFeedLoader = RemoteFeedLoader(url: remoteURL, client: httpClient) + let remoteImageLoader = RemoteFeedImageDataLoader(client: httpClient) let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init) let localImageLoader = LocalFeedImageDataLoader(store: store) @@ -58,8 +56,4 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { decoratee: remoteImageLoader, cache: localImageLoader)))) } - - func makeRemoteClient() -> HTTPClient { - return httpClient - } } From 66c1f7f9193af8d7217cfbe4881e4a81a7f47507 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 5 Mar 2025 23:21:19 -0300 Subject: [PATCH 09/13] Validate feed cache on entering background --- EssentialApp/EssentialApp/SceneDelegate.swift | 9 ++++- .../FeedAcceptanceTests.swift | 35 ++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index a334a3a..8aa22ca 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -22,6 +22,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { .appendingPathComponent("feed-store.sqlite")) }() + private lazy var localFeedLoader: LocalFeedLoader = { + LocalFeedLoader(store: store, currentDate: Date.init) + }() + convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore) { self.init() self.httpClient = httpClient @@ -40,7 +44,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let remoteFeedLoader = RemoteFeedLoader(url: remoteURL, client: httpClient) let remoteImageLoader = RemoteFeedImageDataLoader(client: httpClient) - let localFeedLoader = LocalFeedLoader(store: store, currentDate: Date.init) let localImageLoader = LocalFeedImageDataLoader(store: store) window?.rootViewController = UINavigationController( @@ -56,4 +59,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { decoratee: remoteImageLoader, cache: localImageLoader)))) } + + func sceneWillResignActive(_ scene: UIScene) { + localFeedLoader.validateCache { _ in } + } } diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index 576c1bb..ce4c4b8 100644 --- a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -41,6 +41,22 @@ class FeedAcceptanceTests: XCTestCase { XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 0) } + func test_onEnteringBackground_deletesExpiredFeedCache() { + let store = InMemoryFeedStore.withExpiredFeedCache + + enterBackground(with: store) + + XCTAssertNil(store.feedCache, "Expected to delete expired cache") + } + + func test_onEnteringBackground_keepsNonExpiredFeedCache() { + let store = InMemoryFeedStore.withNonExpiredFeedCache + + enterBackground(with: store) + + XCTAssertNotNil(store.feedCache, "Expected to keep non-expired cache") + } + // MARK: - Helpers private func launch( @@ -55,6 +71,11 @@ class FeedAcceptanceTests: XCTestCase { return nav?.topViewController as! FeedViewController } + private func enterBackground(with store: InMemoryFeedStore) { + let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store) + sut.sceneWillResignActive(UIApplication.shared.connectedScenes.first!) + } + private class HTTPClientStub: HTTPClient { private class Task: HTTPClientTask { func cancel() {} @@ -81,9 +102,13 @@ class FeedAcceptanceTests: XCTestCase { } private class InMemoryFeedStore: FeedStore, FeedImageDataStore { - private var feedCache: CachedFeed? + private(set) var feedCache: CachedFeed? private var feedImageDataCache: [URL: Data] = [:] + private init(feedCache: CachedFeed? = nil) { + self.feedCache = feedCache + } + func deleteCachedFeed(completion: @escaping FeedStore.DeletionCompletion) { feedCache = nil completion(.success(())) @@ -110,6 +135,14 @@ class FeedAcceptanceTests: XCTestCase { static var empty: InMemoryFeedStore { InMemoryFeedStore() } + + static var withExpiredFeedCache: InMemoryFeedStore { + InMemoryFeedStore(feedCache: CachedFeed(feed: [], timestamp: Date.distantPast)) + } + + static var withNonExpiredFeedCache: InMemoryFeedStore { + InMemoryFeedStore(feedCache: CachedFeed(feed: [], timestamp: Date())) + } } private func response(for url: URL) -> (Data, HTTPURLResponse) { From 17f6d275b1bd982bb7f666852d4aec079ca6ba36 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 5 Mar 2025 23:32:31 -0300 Subject: [PATCH 10/13] Move `HTTPClientStub` to a shared scope in a separate file --- .../FeedAcceptanceTests.swift | 25 -------------- .../Helpers/HTTPClientStub.swift | 34 +++++++++++++++++++ 2 files changed, 34 insertions(+), 25 deletions(-) create mode 100644 EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index ce4c4b8..4fdefa2 100644 --- a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -76,31 +76,6 @@ class FeedAcceptanceTests: XCTestCase { sut.sceneWillResignActive(UIApplication.shared.connectedScenes.first!) } - private class HTTPClientStub: HTTPClient { - private class Task: HTTPClientTask { - func cancel() {} - } - - private let stub: (URL) -> HTTPClient.Result - - init(stub: @escaping (URL) -> HTTPClient.Result) { - self.stub = stub - } - - func get(from url: URL, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask { - completion(stub(url)) - return Task() - } - - static var offline: HTTPClientStub { - HTTPClientStub(stub: { _ in .failure(NSError(domain: "offline", code: 0)) }) - } - - static func online(_ stub: @escaping (URL) -> (Data, HTTPURLResponse)) -> HTTPClientStub { - HTTPClientStub { url in .success(stub(url)) } - } - } - private class InMemoryFeedStore: FeedStore, FeedImageDataStore { private(set) var feedCache: CachedFeed? private var feedImageDataCache: [URL: Data] = [:] diff --git a/EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift b/EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift new file mode 100644 index 0000000..b745663 --- /dev/null +++ b/EssentialApp/EssentialAppTests/Helpers/HTTPClientStub.swift @@ -0,0 +1,34 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation +import EssentialFeed + +class HTTPClientStub: HTTPClient { + private class Task: HTTPClientTask { + func cancel() {} + } + + private let stub: (URL) -> HTTPClient.Result + + init(stub: @escaping (URL) -> HTTPClient.Result) { + self.stub = stub + } + + func get(from url: URL, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask { + completion(stub(url)) + return Task() + } +} + +extension HTTPClientStub { + static var offline: HTTPClientStub { + HTTPClientStub(stub: { _ in .failure(NSError(domain: "offline", code: 0)) }) + } + + static func online(_ stub: @escaping (URL) -> (Data, HTTPURLResponse)) -> HTTPClientStub { + HTTPClientStub { url in .success(stub(url)) } + } +} From 6d8901273efc5e19478d53992c98c6c2392312ff Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 5 Mar 2025 23:35:42 -0300 Subject: [PATCH 11/13] Move `InMemoryFeedStore` to a shared scope in a separate file --- .../FeedAcceptanceTests.swift | 46 +-------------- .../Helpers/InMemoryFeedStore.swift | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+), 45 deletions(-) create mode 100644 EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index 4fdefa2..557a135 100644 --- a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -37,7 +37,7 @@ class FeedAcceptanceTests: XCTestCase { func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() { let feed = launch(httpClient: .offline, store: .empty) feed.simulateAppearance() - + XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 0) } @@ -76,50 +76,6 @@ class FeedAcceptanceTests: XCTestCase { sut.sceneWillResignActive(UIApplication.shared.connectedScenes.first!) } - private class InMemoryFeedStore: FeedStore, FeedImageDataStore { - private(set) var feedCache: CachedFeed? - private var feedImageDataCache: [URL: Data] = [:] - - private init(feedCache: CachedFeed? = nil) { - self.feedCache = feedCache - } - - func deleteCachedFeed(completion: @escaping FeedStore.DeletionCompletion) { - feedCache = nil - completion(.success(())) - } - - func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping FeedStore.InsertionCompletion) { - feedCache = CachedFeed(feed: feed, timestamp: timestamp) - completion(.success(())) - } - - func retrieve(completion: @escaping FeedStore.RetrievalCompletion) { - completion(.success(feedCache)) - } - - func insert(_ data: Data, for url: URL, completion: @escaping (FeedImageDataStore.InsertionResult) -> Void) { - feedImageDataCache[url] = data - completion(.success(())) - } - - func retrieve(dataForURL url: URL, completion: @escaping (FeedImageDataStore.RetrievalResult) -> Void) { - completion(.success(feedImageDataCache[url])) - } - - static var empty: InMemoryFeedStore { - InMemoryFeedStore() - } - - static var withExpiredFeedCache: InMemoryFeedStore { - InMemoryFeedStore(feedCache: CachedFeed(feed: [], timestamp: Date.distantPast)) - } - - static var withNonExpiredFeedCache: InMemoryFeedStore { - InMemoryFeedStore(feedCache: CachedFeed(feed: [], timestamp: Date())) - } - } - private func response(for url: URL) -> (Data, HTTPURLResponse) { let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! return (makeData(for: url), response) diff --git a/EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift b/EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift new file mode 100644 index 0000000..a0e3446 --- /dev/null +++ b/EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift @@ -0,0 +1,57 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation +import EssentialFeed + +class InMemoryFeedStore { + private(set) var feedCache: CachedFeed? + private var feedImageDataCache: [URL: Data] = [:] + + private init(feedCache: CachedFeed? = nil) { + self.feedCache = feedCache + } +} + +extension InMemoryFeedStore: FeedStore { + func deleteCachedFeed(completion: @escaping FeedStore.DeletionCompletion) { + feedCache = nil + completion(.success(())) + } + + func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping FeedStore.InsertionCompletion) { + feedCache = CachedFeed(feed: feed, timestamp: timestamp) + completion(.success(())) + } + + func retrieve(completion: @escaping FeedStore.RetrievalCompletion) { + completion(.success(feedCache)) + } +} + +extension InMemoryFeedStore: FeedImageDataStore { + func insert(_ data: Data, for url: URL, completion: @escaping (FeedImageDataStore.InsertionResult) -> Void) { + feedImageDataCache[url] = data + completion(.success(())) + } + + func retrieve(dataForURL url: URL, completion: @escaping (FeedImageDataStore.RetrievalResult) -> Void) { + completion(.success(feedImageDataCache[url])) + } +} + +extension InMemoryFeedStore { + static var empty: InMemoryFeedStore { + InMemoryFeedStore() + } + + static var withExpiredFeedCache: InMemoryFeedStore { + InMemoryFeedStore(feedCache: CachedFeed(feed: [], timestamp: Date.distantPast)) + } + + static var withNonExpiredFeedCache: InMemoryFeedStore { + InMemoryFeedStore(feedCache: CachedFeed(feed: [], timestamp: Date())) + } +} From 6454e3d11e04fb38ca8273870edf24d4eddc45a6 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Thu, 6 Mar 2025 00:01:47 -0300 Subject: [PATCH 12/13] Improve launch method on FeedAcceptanceTests --- EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index 557a135..2ded0ee 100644 --- a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -12,7 +12,6 @@ class FeedAcceptanceTests: XCTestCase { func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() { let feed = launch(httpClient: .online(response), store: .empty) - feed.simulateAppearance() XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 2) XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData()) @@ -22,12 +21,10 @@ class FeedAcceptanceTests: XCTestCase { func test_onLaunch_displaysCachedRemoteFeedWhenCustomerHasNoConnectivity() { let sharedStore = InMemoryFeedStore.empty let onlineFeed = launch(httpClient: .online(response), store: sharedStore) - onlineFeed.simulateAppearance() onlineFeed.simulateFeedImageViewVisible(at: 0) onlineFeed.simulateFeedImageViewVisible(at: 1) let offlineFeed = launch(httpClient: .offline, store: sharedStore) - offlineFeed.simulateAppearance() XCTAssertEqual(offlineFeed.numberOfRenderedFeedImageViews(), 2) XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 0), makeImageData()) @@ -36,7 +33,6 @@ class FeedAcceptanceTests: XCTestCase { func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() { let feed = launch(httpClient: .offline, store: .empty) - feed.simulateAppearance() XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 0) } @@ -68,7 +64,9 @@ class FeedAcceptanceTests: XCTestCase { sut.configureWindow() let nav = sut.window?.rootViewController as? UINavigationController - return nav?.topViewController as! FeedViewController + let vc = nav?.topViewController as! FeedViewController + vc.simulateAppearance() + return vc } private func enterBackground(with store: InMemoryFeedStore) { From 6e5d83a4fc49c140757fe313bf7476c8833492b9 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Thu, 6 Mar 2025 00:07:44 -0300 Subject: [PATCH 13/13] Load feed actions runs automatically only on first appearance --- .../EssentialAppTests/FeedUIIntegrationTests.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift index 919cd16..e00a39d 100644 --- a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift @@ -33,6 +33,17 @@ final class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(loader.loadFeedCallCount, 3, "Expected yet another loading request once user initiates another reload") } + func test_loadFeedActions_runsAutomaticallyOnlyOnFirstAppearance() { + let (sut, loader) = makeSUT() + XCTAssertEqual(loader.loadFeedCallCount, 0, "Expected no loading requests before view appears") + + sut.simulateAppearance() + XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected a loading request once view appears") + + sut.simulateAppearance() + XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected no loading request the second time view appears") + } + func test_loadingFeedIndicator_isVisibleWhileLoadingFeed() { let (sut, loader) = makeSUT()