diff --git a/EssentialApp/EssentialApp/FeedImageDataLoaderPresentationAdapter.swift b/EssentialApp/EssentialApp/FeedImageDataLoaderPresentationAdapter.swift deleted file mode 100644 index 9459480..0000000 --- a/EssentialApp/EssentialApp/FeedImageDataLoaderPresentationAdapter.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2025 PortoCode. All Rights Reserved. -// - -import Combine -import Foundation -import EssentialFeed -import EssentialFeediOS - -final class FeedImageDataLoaderPresentationAdapter: FeedImageCellControllerDelegate where View.Image == Image { - private let model: FeedImage - private let imageLoader: (URL) -> FeedImageDataLoader.Publisher - private var cancellable: Cancellable? - - var presenter: FeedImagePresenter? - - init(model: FeedImage, imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher) { - self.model = model - self.imageLoader = imageLoader - } - - func didRequestImage() { - presenter?.didStartLoadingImageData(for: model) - - let model = self.model - - cancellable = imageLoader(model.url) - .dispatchOnMainQueue() - .sink( - receiveCompletion: { [weak self] completion in - switch completion { - case .finished: break - - case let .failure(error): - self?.presenter?.didFinishLoadingImageData(with: error, for: model) - } - - }, receiveValue: { [weak self] data in - self?.presenter?.didFinishLoadingImageData(with: data, for: model) - }) - } - - func didCancelImageRequest() { - cancellable?.cancel() - } -} diff --git a/EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift b/EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift deleted file mode 100644 index 0b5e9f2..0000000 --- a/EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2025 PortoCode. All Rights Reserved. -// - -import Combine -import EssentialFeed -import EssentialFeediOS - -final class FeedLoaderPresentationAdapter: FeedViewControllerDelegate { - private let feedLoader: () -> AnyPublisher<[FeedImage], Error> - private var cancellable: Cancellable? - var presenter: FeedPresenter? - - init(feedLoader: @escaping () -> AnyPublisher<[FeedImage], Error>) { - self.feedLoader = feedLoader - } - - func didRequestFeedRefresh() { - presenter?.didStartLoadingFeed() - - cancellable = feedLoader() - .dispatchOnMainQueue() - .sink( - receiveCompletion: { [weak self] completion in - switch completion { - case .finished: break - - case let .failure(error): - self?.presenter?.didFinishLoadingFeed(with: error) - } - }, receiveValue: { [weak self] feed in - self?.presenter?.didFinishLoadingFeed(with: feed) - }) - } -} diff --git a/EssentialApp/EssentialApp/FeedUIComposer.swift b/EssentialApp/EssentialApp/FeedUIComposer.swift index 0dc27f1..c719870 100644 --- a/EssentialApp/EssentialApp/FeedUIComposer.swift +++ b/EssentialApp/EssentialApp/FeedUIComposer.swift @@ -11,22 +11,25 @@ import EssentialFeediOS public final class FeedUIComposer { private init() {} + private typealias FeedPresentationAdapter = LoadResourcePresentationAdapter<[FeedImage], FeedViewAdapter> + public static func feedComposedWith( feedLoader: @escaping () -> AnyPublisher<[FeedImage], Error>, imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher ) -> FeedViewController { - let presentationAdapter = FeedLoaderPresentationAdapter(feedLoader: feedLoader) + let presentationAdapter = FeedPresentationAdapter(loader: feedLoader) let feedController = makeFeedViewController( delegate: presentationAdapter, title: FeedPresenter.title) - presentationAdapter.presenter = FeedPresenter( - feedView: FeedViewAdapter( + presentationAdapter.presenter = LoadResourcePresenter( + resourceView: FeedViewAdapter( controller: feedController, imageLoader: imageLoader), loadingView: WeakRefVirtualProxy(feedController), - errorView: WeakRefVirtualProxy(feedController)) + errorView: WeakRefVirtualProxy(feedController), + mapper: FeedPresenter.map) return feedController } diff --git a/EssentialApp/EssentialApp/FeedViewAdapter.swift b/EssentialApp/EssentialApp/FeedViewAdapter.swift index a981611..80791fc 100644 --- a/EssentialApp/EssentialApp/FeedViewAdapter.swift +++ b/EssentialApp/EssentialApp/FeedViewAdapter.swift @@ -7,10 +7,12 @@ import UIKit import EssentialFeed import EssentialFeediOS -final class FeedViewAdapter: FeedView { +final class FeedViewAdapter: ResourceView { private weak var controller: FeedViewController? private let imageLoader: (URL) -> FeedImageDataLoader.Publisher + private typealias ImageDataPresentationAdapter = LoadResourcePresentationAdapter> + init(controller: FeedViewController? = nil, imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher) { self.controller = controller self.imageLoader = imageLoader @@ -18,14 +20,32 @@ final class FeedViewAdapter: FeedView { func display(_ viewModel: FeedViewModel) { controller?.display(viewModel.feed.map { model in - let adapter = FeedImageDataLoaderPresentationAdapter, UIImage>(model: model, imageLoader: imageLoader) - let view = FeedImageCellController(delegate: adapter) + let adapter = ImageDataPresentationAdapter(loader: { [imageLoader] in + imageLoader(model.url) + }) + + let view = FeedImageCellController( + viewModel: FeedImagePresenter.map(model), + delegate: adapter) - adapter.presenter = FeedImagePresenter( - view: WeakRefVirtualProxy(view), - imageTransformer: UIImage.init) + adapter.presenter = LoadResourcePresenter( + resourceView: WeakRefVirtualProxy(view), + loadingView: WeakRefVirtualProxy(view), + errorView: WeakRefVirtualProxy(view), + mapper: UIImage.tryMake) return view }) } } + +extension UIImage { + struct InvalidImageData: Error {} + + static func tryMake(data: Data) throws -> UIImage { + guard let image = UIImage(data: data) else { + throw InvalidImageData() + } + return image + } +} diff --git a/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift b/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift new file mode 100644 index 0000000..77af513 --- /dev/null +++ b/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift @@ -0,0 +1,53 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Combine +import EssentialFeed +import EssentialFeediOS + +final class LoadResourcePresentationAdapter { + private let loader: () -> AnyPublisher + private var cancellable: Cancellable? + var presenter: LoadResourcePresenter? + + init(loader: @escaping () -> AnyPublisher) { + self.loader = loader + } + + func loadResource() { + presenter?.didStartLoading() + + cancellable = loader() + .dispatchOnMainQueue() + .sink( + receiveCompletion: { [weak self] completion in + switch completion { + case .finished: break + + case let .failure(error): + self?.presenter?.didFinishLoading(with: error) + } + }, receiveValue: { [weak self] resource in + self?.presenter?.didFinishLoading(with: resource) + }) + } +} + +extension LoadResourcePresentationAdapter: FeedViewControllerDelegate { + func didRequestFeedRefresh() { + loadResource() + } +} + +extension LoadResourcePresentationAdapter: FeedImageCellControllerDelegate { + func didRequestImage() { + loadResource() + } + + func didCancelImageRequest() { + cancellable?.cancel() + cancellable = nil + } +} diff --git a/EssentialApp/EssentialApp/WeakRefVirtualProxy.swift b/EssentialApp/EssentialApp/WeakRefVirtualProxy.swift index 594dbde..2028f7f 100644 --- a/EssentialApp/EssentialApp/WeakRefVirtualProxy.swift +++ b/EssentialApp/EssentialApp/WeakRefVirtualProxy.swift @@ -15,20 +15,20 @@ final class WeakRefVirtualProxy { } } -extension WeakRefVirtualProxy: FeedErrorView where T: FeedErrorView { - func display(_ viewModel: FeedErrorViewModel) { +extension WeakRefVirtualProxy: ResourceErrorView where T: ResourceErrorView { + func display(_ viewModel: ResourceErrorViewModel) { object?.display(viewModel) } } -extension WeakRefVirtualProxy: FeedLoadingView where T: FeedLoadingView { - func display(_ viewModel: FeedLoadingViewModel) { +extension WeakRefVirtualProxy: ResourceLoadingView where T: ResourceLoadingView { + func display(_ viewModel: ResourceLoadingViewModel) { object?.display(viewModel) } } -extension WeakRefVirtualProxy: FeedImageView where T: FeedImageView, T.Image == UIImage { - func display(_ model: FeedImageViewModel) { +extension WeakRefVirtualProxy: ResourceView where T: ResourceView, T.ResourceViewModel == UIImage { + func display(_ model: UIImage) { object?.display(model) } } diff --git a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift index 2865e92..1c12062 100644 --- a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift @@ -16,7 +16,7 @@ final class FeedUIIntegrationTests: XCTestCase { sut.simulateAppearance() - XCTAssertEqual(sut.title, localized("FEED_VIEW_TITLE")) + XCTAssertEqual(sut.title, feedTitle) } func test_loadFeedActions_requestFeedFromLoader() { @@ -112,7 +112,7 @@ final class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(sut.errorMessage, nil) loader.completeFeedLoadingWithError(at: 0) - XCTAssertEqual(sut.errorMessage, localized("FEED_VIEW_CONNECTION_ERROR")) + XCTAssertEqual(sut.errorMessage, loadError) sut.simulateUserInitiatedFeedReload() XCTAssertEqual(sut.errorMessage, nil) diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift index 268e8d0..d09595d 100644 --- a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift +++ b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift @@ -8,13 +8,15 @@ 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 + private class DummyView: ResourceView { + func display(_ viewModel: Any) {} + } + + var loadError: String { + LoadResourcePresenter.loadError + } + + var feedTitle: String { + FeedPresenter.title } } diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 717b06b..93af5c0 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -39,6 +39,16 @@ 5B7349162D81A28D007F7D5D /* ImageCommentsMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349152D81A28D007F7D5D /* ImageCommentsMapper.swift */; }; 5B7349192D824CC8007F7D5D /* ImageComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349182D824CC8007F7D5D /* ImageComment.swift */; }; 5B7349282D829960007F7D5D /* FeedImageDataMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349272D829960007F7D5D /* FeedImageDataMapper.swift */; }; + 5B73492B2D843A4F007F7D5D /* LoadResourcePresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B73492A2D843A4F007F7D5D /* LoadResourcePresenterTests.swift */; }; + 5B73492E2D843BBE007F7D5D /* LoadResourcePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B73492D2D843BBE007F7D5D /* LoadResourcePresenter.swift */; }; + 5B7349332D844B03007F7D5D /* Shared.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5B7349322D844B03007F7D5D /* Shared.xcstrings */; }; + 5B7349352D844D6D007F7D5D /* SharedLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349342D844D6D007F7D5D /* SharedLocalizationTests.swift */; }; + 5B7349372D84E167007F7D5D /* SharedLocalizationTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349362D84E167007F7D5D /* SharedLocalizationTestHelpers.swift */; }; + 5B7349382D84E167007F7D5D /* SharedLocalizationTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349362D84E167007F7D5D /* SharedLocalizationTestHelpers.swift */; }; + 5B7349392D84E167007F7D5D /* SharedLocalizationTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349362D84E167007F7D5D /* SharedLocalizationTestHelpers.swift */; }; + 5B73493A2D84E167007F7D5D /* SharedLocalizationTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7349362D84E167007F7D5D /* SharedLocalizationTestHelpers.swift */; }; + 5B73493C2D84E463007F7D5D /* ResourceLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B73493B2D84E463007F7D5D /* ResourceLoadingView.swift */; }; + 5B73493E2D84FA14007F7D5D /* ResourceErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B73493D2D84FA14007F7D5D /* ResourceErrorView.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 */; }; @@ -46,8 +56,8 @@ 5B8829032D6A7401006E0BD7 /* FeedPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829022D6A7401006E0BD7 /* FeedPresenter.swift */; }; 5B8829042D6A7527006E0BD7 /* Feed.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5B8828FF2D6A7137006E0BD7 /* Feed.xcstrings */; }; 5B8829062D6A7A9A006E0BD7 /* FeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829052D6A7A9A006E0BD7 /* FeedViewModel.swift */; }; - 5B8829082D6A7B12006E0BD7 /* FeedLoadingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829072D6A7B12006E0BD7 /* FeedLoadingViewModel.swift */; }; - 5B88290A2D6A7B76006E0BD7 /* FeedErrorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829092D6A7B76006E0BD7 /* FeedErrorViewModel.swift */; }; + 5B8829082D6A7B12006E0BD7 /* ResourceLoadingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829072D6A7B12006E0BD7 /* ResourceLoadingViewModel.swift */; }; + 5B88290A2D6A7B76006E0BD7 /* ResourceErrorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829092D6A7B76006E0BD7 /* ResourceErrorViewModel.swift */; }; 5B88290B2D6A8133006E0BD7 /* FeedLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8AB3732D5ECCBF00CDDDEB /* FeedLocalizationTests.swift */; }; 5B88290D2D6A82D3006E0BD7 /* FeedImagePresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B88290C2D6A82D3006E0BD7 /* FeedImagePresenterTests.swift */; }; 5B88290F2D6A94C3006E0BD7 /* FeedImagePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B88290E2D6A94C3006E0BD7 /* FeedImagePresenter.swift */; }; @@ -182,6 +192,13 @@ 5B7349152D81A28D007F7D5D /* ImageCommentsMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCommentsMapper.swift; sourceTree = ""; }; 5B7349182D824CC8007F7D5D /* ImageComment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageComment.swift; sourceTree = ""; }; 5B7349272D829960007F7D5D /* FeedImageDataMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageDataMapper.swift; sourceTree = ""; }; + 5B73492A2D843A4F007F7D5D /* LoadResourcePresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadResourcePresenterTests.swift; sourceTree = ""; }; + 5B73492D2D843BBE007F7D5D /* LoadResourcePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadResourcePresenter.swift; sourceTree = ""; }; + 5B7349322D844B03007F7D5D /* Shared.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Shared.xcstrings; sourceTree = ""; }; + 5B7349342D844D6D007F7D5D /* SharedLocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedLocalizationTests.swift; sourceTree = ""; }; + 5B7349362D84E167007F7D5D /* SharedLocalizationTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedLocalizationTestHelpers.swift; sourceTree = ""; }; + 5B73493B2D84E463007F7D5D /* ResourceLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceLoadingView.swift; sourceTree = ""; }; + 5B73493D2D84FA14007F7D5D /* ResourceErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceErrorView.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 = ""; }; 5B74FD212D6A5F39007478DC /* FeedPresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPresenterTests.swift; sourceTree = ""; }; @@ -189,8 +206,8 @@ 5B8828FF2D6A7137006E0BD7 /* Feed.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Feed.xcstrings; sourceTree = ""; }; 5B8829022D6A7401006E0BD7 /* FeedPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPresenter.swift; sourceTree = ""; }; 5B8829052D6A7A9A006E0BD7 /* FeedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedViewModel.swift; sourceTree = ""; }; - 5B8829072D6A7B12006E0BD7 /* FeedLoadingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLoadingViewModel.swift; sourceTree = ""; }; - 5B8829092D6A7B76006E0BD7 /* FeedErrorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedErrorViewModel.swift; sourceTree = ""; }; + 5B8829072D6A7B12006E0BD7 /* ResourceLoadingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceLoadingViewModel.swift; sourceTree = ""; }; + 5B8829092D6A7B76006E0BD7 /* ResourceErrorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceErrorViewModel.swift; sourceTree = ""; }; 5B88290C2D6A82D3006E0BD7 /* FeedImagePresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImagePresenterTests.swift; sourceTree = ""; }; 5B88290E2D6A94C3006E0BD7 /* FeedImagePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImagePresenter.swift; sourceTree = ""; }; 5B8829102D6A964F006E0BD7 /* FeedImageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageViewModel.swift; sourceTree = ""; }; @@ -355,6 +372,7 @@ isa = PBXGroup; children = ( 5B107DFB2BF5BB2100927709 /* EssentialFeed.h */, + 5B73492C2D843BA4007F7D5D /* Shared Presentation */, 5B73491A2D8255FF007F7D5D /* Shared API */, 5B73491B2D825610007F7D5D /* Shared API Infra */, 5B7349172D824BF6007F7D5D /* Image Comments Feature */, @@ -372,6 +390,7 @@ children = ( 5B1C4F9C2C056BB6003F0429 /* EssentialFeed.xctestplan */, 5B8BB9972C02714D00D40D42 /* Helpers */, + 5B7349292D8439FF007F7D5D /* Shared Presentation */, 5B73491E2D825711007F7D5D /* Shared API Infra */, 5B7349222D8258A5007F7D5D /* Image Comments API */, 5B0E220E2BFE404F009FC3EB /* Feed API */, @@ -506,6 +525,28 @@ path = "Image Comments API"; sourceTree = ""; }; + 5B7349292D8439FF007F7D5D /* Shared Presentation */ = { + isa = PBXGroup; + children = ( + 5B73492A2D843A4F007F7D5D /* LoadResourcePresenterTests.swift */, + 5B7349342D844D6D007F7D5D /* SharedLocalizationTests.swift */, + ); + path = "Shared Presentation"; + sourceTree = ""; + }; + 5B73492C2D843BA4007F7D5D /* Shared Presentation */ = { + isa = PBXGroup; + children = ( + 5B73492D2D843BBE007F7D5D /* LoadResourcePresenter.swift */, + 5B73493B2D84E463007F7D5D /* ResourceLoadingView.swift */, + 5B8829072D6A7B12006E0BD7 /* ResourceLoadingViewModel.swift */, + 5B73493D2D84FA14007F7D5D /* ResourceErrorView.swift */, + 5B8829092D6A7B76006E0BD7 /* ResourceErrorViewModel.swift */, + 5B7349322D844B03007F7D5D /* Shared.xcstrings */, + ); + path = "Shared Presentation"; + sourceTree = ""; + }; 5B74FD1B2D64A6DE007478DC /* Helpers */ = { isa = PBXGroup; children = ( @@ -544,8 +585,6 @@ 5B8829022D6A7401006E0BD7 /* FeedPresenter.swift */, 5B88290E2D6A94C3006E0BD7 /* FeedImagePresenter.swift */, 5B8829052D6A7A9A006E0BD7 /* FeedViewModel.swift */, - 5B8829072D6A7B12006E0BD7 /* FeedLoadingViewModel.swift */, - 5B8829092D6A7B76006E0BD7 /* FeedErrorViewModel.swift */, 5B8829102D6A964F006E0BD7 /* FeedImageViewModel.swift */, 5B8828FF2D6A7137006E0BD7 /* Feed.xcstrings */, ); @@ -573,6 +612,7 @@ children = ( 5B8BB9982C02719F00D40D42 /* XCTestCase+MemoryLeakTracking.swift */, 5B034B442CA3A1A100FB65F8 /* SharedTestHelpers.swift */, + 5B7349362D84E167007F7D5D /* SharedLocalizationTestHelpers.swift */, ); path = Helpers; sourceTree = ""; @@ -847,6 +887,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5B7349332D844B03007F7D5D /* Shared.xcstrings in Resources */, 5B8829042D6A7527006E0BD7 /* Feed.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -899,6 +940,7 @@ 5BC4F6CB2CDAF0B20002D4CF /* CoreDataHelpers.swift in Sources */, 5B0E220D2BFE3135009FC3EB /* FeedItemsMapper.swift in Sources */, 5B8829112D6A964F006E0BD7 /* FeedImageViewModel.swift in Sources */, + 5B73493E2D84FA14007F7D5D /* ResourceErrorView.swift in Sources */, 5B7349282D829960007F7D5D /* FeedImageDataMapper.swift in Sources */, 5BE36BA62CD5845700ACC57C /* FeedCachePolicy.swift in Sources */, 5B7349192D824CC8007F7D5D /* ImageComment.swift in Sources */, @@ -909,18 +951,20 @@ 5B034B352C9A819900FB65F8 /* FeedStore.swift in Sources */, 5B034B332C9A804C00FB65F8 /* LocalFeedLoader.swift in Sources */, 5B7349162D81A28D007F7D5D /* ImageCommentsMapper.swift in Sources */, + 5B73492E2D843BBE007F7D5D /* LoadResourcePresenter.swift in Sources */, 5B0E220B2BFE2FEA009FC3EB /* HTTPClient.swift in Sources */, 5B107E132BF5BB4200927709 /* FeedImage.swift in Sources */, 5BBDA01A2D6FF5F100D68DF0 /* FeedImageDataCache.swift in Sources */, 5B8829242D6BEB71006E0BD7 /* FeedImageDataStore.swift in Sources */, 5B8829032D6A7401006E0BD7 /* FeedPresenter.swift in Sources */, 5B1C4F9B2C0556ED003F0429 /* URLSessionHTTPClient.swift in Sources */, + 5B73493C2D84E463007F7D5D /* ResourceLoadingView.swift in Sources */, 5B88291C2D6BD6C6006E0BD7 /* HTTPURLResponse+StatusCode.swift in Sources */, 5BC4F6CD2CDAF1B30002D4CF /* ManagedCache.swift in Sources */, 5BF9F30A2CDAD24D00C8DB96 /* CoreDataFeedStore.swift in Sources */, - 5B88290A2D6A7B76006E0BD7 /* FeedErrorViewModel.swift in Sources */, + 5B88290A2D6A7B76006E0BD7 /* ResourceErrorViewModel.swift in Sources */, 5B8829142D6BAE59006E0BD7 /* FeedImageDataLoader.swift in Sources */, - 5B8829082D6A7B12006E0BD7 /* FeedLoadingViewModel.swift in Sources */, + 5B8829082D6A7B12006E0BD7 /* ResourceLoadingViewModel.swift in Sources */, 5BDE3C652D6C19D8005D520D /* CoreDataFeedStore+FeedImageDataLoader.swift in Sources */, 5B88290F2D6A94C3006E0BD7 /* FeedImagePresenter.swift in Sources */, 5BBDA00E2D6FCCF000D68DF0 /* FeedCache.swift in Sources */, @@ -933,6 +977,7 @@ buildActionMask = 2147483647; files = ( 5B034B432CA3A0C800FB65F8 /* FeedCacheTestHelpers.swift in Sources */, + 5B73493A2D84E167007F7D5D /* SharedLocalizationTestHelpers.swift in Sources */, 5B88291E2D6BD7BE006E0BD7 /* URLProtocolStub.swift in Sources */, 5B034B412CA371CB00FB65F8 /* ValidateFeedCacheUseCaseTests.swift in Sources */, 5B74FD222D6A5F39007478DC /* FeedPresenterTests.swift in Sources */, @@ -948,12 +993,14 @@ 5B034B3F2CA0BAA500FB65F8 /* FeedStoreSpy.swift in Sources */, 5B034B452CA3A1A100FB65F8 /* SharedTestHelpers.swift in Sources */, 5B8BD18F2C3798D400CCA870 /* CacheFeedUseCaseTests.swift in Sources */, + 5B7349352D844D6D007F7D5D /* SharedLocalizationTests.swift in Sources */, 5B8829262D6BF168006E0BD7 /* FeedImageDataStoreSpy.swift in Sources */, 5B88292B2D6BF4F0006E0BD7 /* CoreDataFeedImageDataStoreTests.swift in Sources */, 5BF9F3032CD9A1C600C8DB96 /* XCTestCase+FailableDeleteFeedStoreSpecs.swift in Sources */, 5BF9F2FF2CD99FF300C8DB96 /* XCTestCase+FailableRetrieveFeedStoreSpecs.swift in Sources */, 5B034B3B2CA0B09F00FB65F8 /* LoadFeedFromCacheUseCaseTests.swift in Sources */, 5BF9F2F92CD9961400C8DB96 /* FeedStoreSpecs.swift in Sources */, + 5B73492B2D843A4F007F7D5D /* LoadResourcePresenterTests.swift in Sources */, 5B8BB9992C02719F00D40D42 /* XCTestCase+MemoryLeakTracking.swift in Sources */, 5BF9F3082CDAD1B600C8DB96 /* CoreDataFeedStoreTests.swift in Sources */, 5BF9F2FB2CD997F300C8DB96 /* XCTestCase+FeedStoreSpecs.swift in Sources */, @@ -965,6 +1012,7 @@ buildActionMask = 2147483647; files = ( 5B1C4FC92C057236003F0429 /* EssentialFeedAPIEndToEndTests.swift in Sources */, + 5B7349392D84E167007F7D5D /* SharedLocalizationTestHelpers.swift in Sources */, 5B1C4FD02C0579EE003F0429 /* XCTestCase+MemoryLeakTracking.swift in Sources */, 5B034B462CA3A1A100FB65F8 /* SharedTestHelpers.swift in Sources */, ); @@ -991,6 +1039,7 @@ buildActionMask = 2147483647; files = ( 5B6992972CFE8D8B00DD47E9 /* XCTestCase+MemoryLeakTracking.swift in Sources */, + 5B7349382D84E167007F7D5D /* SharedLocalizationTestHelpers.swift in Sources */, 5BBDA1AD2D7CCA8000D68DF0 /* FeedSnapshotTests.swift in Sources */, 5BB735192D7D0CB000189186 /* XCTestCase+Snapshot.swift in Sources */, 5BB735172D7D0BEE00189186 /* UIViewController+Snapshot.swift in Sources */, @@ -1003,6 +1052,7 @@ buildActionMask = 2147483647; files = ( 5BA598C02CE19EE0007B1795 /* SharedTestHelpers.swift in Sources */, + 5B7349372D84E167007F7D5D /* SharedLocalizationTestHelpers.swift in Sources */, 5BA598BC2CE194BC007B1795 /* EssentialFeedCacheIntegrationTests.swift in Sources */, 5B8829272D6BF168006E0BD7 /* FeedImageDataStoreSpy.swift in Sources */, 5BA598BF2CE19E50007B1795 /* FeedCacheTestHelpers.swift in Sources */, diff --git a/EssentialFeed/EssentialFeed/Feed Presentation/Feed.xcstrings b/EssentialFeed/EssentialFeed/Feed Presentation/Feed.xcstrings index f309d60..d7e4481 100644 --- a/EssentialFeed/EssentialFeed/Feed Presentation/Feed.xcstrings +++ b/EssentialFeed/EssentialFeed/Feed Presentation/Feed.xcstrings @@ -1,30 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "FEED_VIEW_CONNECTION_ERROR" : { - "comment" : "Error message displayed when we can't load the image feed from the server", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Couldn't connect to server" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "No se pudo conectar al servidor" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Não foi possível conectar ao servidor" - } - } - } - }, "FEED_VIEW_TITLE" : { "comment" : "Title for the feed view", "extractionState" : "manual", diff --git a/EssentialFeed/EssentialFeed/Feed Presentation/FeedErrorViewModel.swift b/EssentialFeed/EssentialFeed/Feed Presentation/FeedErrorViewModel.swift deleted file mode 100644 index 7befad6..0000000 --- a/EssentialFeed/EssentialFeed/Feed Presentation/FeedErrorViewModel.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2025 PortoCode. All Rights Reserved. -// - -public struct FeedErrorViewModel { - public let message: String? - - static var noError: FeedErrorViewModel { - return FeedErrorViewModel(message: nil) - } - - static func error(message: String) -> FeedErrorViewModel { - return FeedErrorViewModel(message: message) - } -} diff --git a/EssentialFeed/EssentialFeed/Feed Presentation/FeedImagePresenter.swift b/EssentialFeed/EssentialFeed/Feed Presentation/FeedImagePresenter.swift index b8a16ca..37c2de1 100644 --- a/EssentialFeed/EssentialFeed/Feed Presentation/FeedImagePresenter.swift +++ b/EssentialFeed/EssentialFeed/Feed Presentation/FeedImagePresenter.swift @@ -5,46 +5,10 @@ import Foundation -public protocol FeedImageView { - associatedtype Image - - func display(_ model: FeedImageViewModel) -} - -public class FeedImagePresenter where View.Image == Image { - private let view: View - private let imageTransformer: (Data) -> Image? - - public init(view: View, imageTransformer: @escaping (Data) -> Image?) { - self.view = view - self.imageTransformer = imageTransformer - } - - public func didStartLoadingImageData(for model: FeedImage) { - view.display(FeedImageViewModel( - description: model.description, - location: model.location, - image: nil, - isLoading: true, - shouldRetry: false)) - } - - public func didFinishLoadingImageData(with data: Data, for model: FeedImage) { - let image = imageTransformer(data) - view.display(FeedImageViewModel( - description: model.description, - location: model.location, - image: image, - isLoading: false, - shouldRetry: image == nil)) - } - - public func didFinishLoadingImageData(with error: Error, for model: FeedImage) { - view.display(FeedImageViewModel( - description: model.description, - location: model.location, - image: nil, - isLoading: false, - shouldRetry: true)) +public class FeedImagePresenter { + public static func map(_ image: FeedImage) -> FeedImageViewModel { + FeedImageViewModel( + description: image.description, + location: image.location) } } diff --git a/EssentialFeed/EssentialFeed/Feed Presentation/FeedImageViewModel.swift b/EssentialFeed/EssentialFeed/Feed Presentation/FeedImageViewModel.swift index 885c83e..d635b85 100644 --- a/EssentialFeed/EssentialFeed/Feed Presentation/FeedImageViewModel.swift +++ b/EssentialFeed/EssentialFeed/Feed Presentation/FeedImageViewModel.swift @@ -3,12 +3,9 @@ // Copyright © 2025 PortoCode. All Rights Reserved. // -public struct FeedImageViewModel { +public struct FeedImageViewModel { public let description: String? public let location: String? - public let image: Image? - public let isLoading: Bool - public let shouldRetry: Bool public var hasLocation: Bool { return location != nil diff --git a/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift b/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift index 178cc7b..b8f6900 100644 --- a/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift +++ b/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift @@ -3,57 +3,18 @@ // Copyright © 2025 PortoCode. All Rights Reserved. // -public protocol FeedView { - func display(_ viewModel: FeedViewModel) -} - -public protocol FeedLoadingView { - func display(_ viewModel: FeedLoadingViewModel) -} - -public protocol FeedErrorView { - func display(_ viewModel: FeedErrorViewModel) -} +import Foundation public final class FeedPresenter { - private let feedView: FeedView - private let loadingView: FeedLoadingView - private let errorView: FeedErrorView - - private var feedLoadError: String { - return NSLocalizedString( - "FEED_VIEW_CONNECTION_ERROR", - tableName: "Feed", - bundle: Bundle(for: FeedPresenter.self), - comment: "Error message displayed when we can't load the image feed from the server") - } - public static var title: String { - return NSLocalizedString( + NSLocalizedString( "FEED_VIEW_TITLE", tableName: "Feed", bundle: Bundle(for: FeedPresenter.self), comment: "Title for the feed view") } - public init(feedView: FeedView, loadingView: FeedLoadingView, errorView: FeedErrorView) { - self.feedView = feedView - self.loadingView = loadingView - self.errorView = errorView - } - - public func didStartLoadingFeed() { - errorView.display(.noError) - loadingView.display(FeedLoadingViewModel(isLoading: true)) - } - - public func didFinishLoadingFeed(with feed: [FeedImage]) { - feedView.display(FeedViewModel(feed: feed)) - loadingView.display(FeedLoadingViewModel(isLoading: false)) - } - - public func didFinishLoadingFeed(with error: Error) { - errorView.display(.error(message: feedLoadError)) - loadingView.display(FeedLoadingViewModel(isLoading: false)) + public static func map(_ feed: [FeedImage]) -> FeedViewModel { + FeedViewModel(feed: feed) } } diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift new file mode 100644 index 0000000..2cdbeaf --- /dev/null +++ b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift @@ -0,0 +1,55 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation + +public protocol ResourceView { + associatedtype ResourceViewModel + + func display(_ viewModel: ResourceViewModel) +} + +public final class LoadResourcePresenter { + public typealias Mapper = (Resource) throws -> View.ResourceViewModel + + private let resourceView: View + private let loadingView: ResourceLoadingView + private let errorView: ResourceErrorView + private let mapper: Mapper + + public static var loadError: String { + NSLocalizedString( + "GENERIC_CONNECTION_ERROR", + tableName: "Shared", + bundle: Bundle(for: Self.self), + comment: "Error message displayed when we can't load the resource from the server") + } + + public init(resourceView: View, loadingView: ResourceLoadingView, errorView: ResourceErrorView, mapper: @escaping Mapper) { + self.resourceView = resourceView + self.loadingView = loadingView + self.errorView = errorView + self.mapper = mapper + } + + public func didStartLoading() { + errorView.display(.noError) + loadingView.display(ResourceLoadingViewModel(isLoading: true)) + } + + public func didFinishLoading(with resource: Resource) { + do { + resourceView.display(try mapper(resource)) + loadingView.display(ResourceLoadingViewModel(isLoading: false)) + } catch { + didFinishLoading(with: error) + } + } + + public func didFinishLoading(with error: Error) { + errorView.display(.error(message: Self.loadError)) + loadingView.display(ResourceLoadingViewModel(isLoading: false)) + } +} diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/ResourceErrorView.swift b/EssentialFeed/EssentialFeed/Shared Presentation/ResourceErrorView.swift new file mode 100644 index 0000000..08e0bb3 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Shared Presentation/ResourceErrorView.swift @@ -0,0 +1,10 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation + +public protocol ResourceErrorView { + func display(_ viewModel: ResourceErrorViewModel) +} diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/ResourceErrorViewModel.swift b/EssentialFeed/EssentialFeed/Shared Presentation/ResourceErrorViewModel.swift new file mode 100644 index 0000000..d9207f3 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Shared Presentation/ResourceErrorViewModel.swift @@ -0,0 +1,16 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +public struct ResourceErrorViewModel { + public let message: String? + + static var noError: ResourceErrorViewModel { + return ResourceErrorViewModel(message: nil) + } + + static func error(message: String) -> ResourceErrorViewModel { + return ResourceErrorViewModel(message: message) + } +} diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/ResourceLoadingView.swift b/EssentialFeed/EssentialFeed/Shared Presentation/ResourceLoadingView.swift new file mode 100644 index 0000000..fe40090 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Shared Presentation/ResourceLoadingView.swift @@ -0,0 +1,10 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation + +public protocol ResourceLoadingView { + func display(_ viewModel: ResourceLoadingViewModel) +} diff --git a/EssentialFeed/EssentialFeed/Feed Presentation/FeedLoadingViewModel.swift b/EssentialFeed/EssentialFeed/Shared Presentation/ResourceLoadingViewModel.swift similarity index 74% rename from EssentialFeed/EssentialFeed/Feed Presentation/FeedLoadingViewModel.swift rename to EssentialFeed/EssentialFeed/Shared Presentation/ResourceLoadingViewModel.swift index d34fa85..f11e595 100644 --- a/EssentialFeed/EssentialFeed/Feed Presentation/FeedLoadingViewModel.swift +++ b/EssentialFeed/EssentialFeed/Shared Presentation/ResourceLoadingViewModel.swift @@ -3,6 +3,6 @@ // Copyright © 2025 PortoCode. All Rights Reserved. // -public struct FeedLoadingViewModel { +public struct ResourceLoadingViewModel { public let isLoading: Bool } diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/Shared.xcstrings b/EssentialFeed/EssentialFeed/Shared Presentation/Shared.xcstrings new file mode 100644 index 0000000..b6512e5 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Shared Presentation/Shared.xcstrings @@ -0,0 +1,30 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "GENERIC_CONNECTION_ERROR" : { + "comment" : "Error message displayed when we can't load the resource from the server", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't connect to server" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudo conectar al servidor" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Não foi possível conectar ao servidor" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift index d8bb209..3a3afe6 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift @@ -8,99 +8,13 @@ import EssentialFeed class FeedImagePresenterTests: XCTestCase { - func test_init_doesNotSendMessagesToView() { - let (_, view) = makeSUT() - - XCTAssertTrue(view.messages.isEmpty, "Expected no view messages") - } - - func test_didStartLoadingImageData_displaysLoadingImage() { - let (sut, view) = makeSUT() - let image = uniqueImage() - - sut.didStartLoadingImageData(for: image) - - let message = view.messages.first - XCTAssertEqual(view.messages.count, 1) - XCTAssertEqual(message?.description, image.description) - XCTAssertEqual(message?.location, image.location) - XCTAssertEqual(message?.isLoading, true) - XCTAssertEqual(message?.shouldRetry, false) - XCTAssertNil(message?.image) - } - - func test_didFinishLoadingImageData_displaysRetryOnFailedImageTransformation() { - let (sut, view) = makeSUT(imageTransformer: fail) + func test_map_createsViewModel() { let image = uniqueImage() - sut.didFinishLoadingImageData(with: Data(), for: image) - - let message = view.messages.first - XCTAssertEqual(view.messages.count, 1) - XCTAssertEqual(message?.description, image.description) - XCTAssertEqual(message?.location, image.location) - XCTAssertEqual(message?.isLoading, false) - XCTAssertEqual(message?.shouldRetry, true) - XCTAssertNil(message?.image) - } - - func test_didFinishLoadingImageData_displaysImageOnSuccessfulTransformation() { - let image = uniqueImage() - let transformedData = AnyImage() - let (sut, view) = makeSUT(imageTransformer: { _ in transformedData }) - - sut.didFinishLoadingImageData(with: Data(), for: image) - - let message = view.messages.first - XCTAssertEqual(view.messages.count, 1) - XCTAssertEqual(message?.description, image.description) - XCTAssertEqual(message?.location, image.location) - XCTAssertEqual(message?.isLoading, false) - XCTAssertEqual(message?.shouldRetry, false) - XCTAssertEqual(message?.image, transformedData) - } - - func test_didFinishLoadingImageDataWithError_displaysRetry() { - let image = uniqueImage() - let (sut, view) = makeSUT() - - sut.didFinishLoadingImageData(with: anyNSError(), for: image) - - let message = view.messages.first - XCTAssertEqual(view.messages.count, 1) - XCTAssertEqual(message?.description, image.description) - XCTAssertEqual(message?.location, image.location) - XCTAssertEqual(message?.isLoading, false) - XCTAssertEqual(message?.shouldRetry, true) - XCTAssertNil(message?.image) - } - - // MARK: - Helpers - - private func makeSUT( - imageTransformer: @escaping (Data) -> AnyImage? = { _ in nil }, - file: StaticString = #file, - line: UInt = #line - ) -> (sut: FeedImagePresenter, view: ViewSpy) { - let view = ViewSpy() - let sut = FeedImagePresenter(view: view, imageTransformer: imageTransformer) - trackForMemoryLeaks(view, file: file, line: line) - trackForMemoryLeaks(sut, file: file, line: line) - return (sut, view) - } - - private var fail: (Data) -> AnyImage? { - return { _ in nil } - } - - private struct AnyImage: Equatable {} - - private class ViewSpy: FeedImageView { - private(set) var messages = [FeedImageViewModel]() + let viewModel = FeedImagePresenter.map(image) - func display(_ model: FeedImageViewModel) { - messages.append(model) - } + XCTAssertEqual(viewModel.description, image.description) + XCTAssertEqual(viewModel.location, image.location) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedLocalizationTests.swift b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedLocalizationTests.swift index e797574..502af3d 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedLocalizationTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedLocalizationTests.swift @@ -10,53 +10,9 @@ final class FeedLocalizationTests: XCTestCase { func test_localizedStrings_haveKeysAndValuesForAllSupportedLocalizations() { let table = "Feed" - let presentationBundle = Bundle(for: FeedPresenter.self) - let localizationBundles = allLocalizationBundles(in: presentationBundle) - let localizedStringKeys = allLocalizedStringKeys(in: localizationBundles, table: table) + let bundle = Bundle(for: FeedPresenter.self) - localizationBundles.forEach { (bundle, localization) in - localizedStringKeys.forEach { key in - let localizedString = bundle.localizedString(forKey: key, value: nil, table: table) - - if localizedString == key { - let language = Locale.current.localizedString(forLanguageCode: localization) ?? "" - - XCTFail("Missing \(language) (\(localization)) localized string for key: '\(key)' in table: '\(table)'") - } - } - } + assertLocalizedKeyAndValuesExist(in: bundle, table) } - // MARK: - Helpers - - private typealias LocalizedBundle = (bundle: Bundle, localization: String) - - private func allLocalizationBundles(in bundle: Bundle, file: StaticString = #file, line: UInt = #line) -> [LocalizedBundle] { - return bundle.localizations.compactMap { localization in - guard - let path = bundle.path(forResource: localization, ofType: "lproj"), - let localizedBundle = Bundle(path: path) - else { - XCTFail("Couldn't find bundle for localization: \(localization)", file: file, line: line) - return nil - } - - return (localizedBundle, localization) - } - } - - private func allLocalizedStringKeys(in bundles: [LocalizedBundle], table: String, file: StaticString = #file, line: UInt = #line) -> Set { - return bundles.reduce([]) { (acc, current) in - guard - let path = current.bundle.path(forResource: table, ofType: "strings"), - let strings = NSDictionary(contentsOfFile: path), - let keys = strings.allKeys as? [String] - else { - XCTFail("Couldn't load localized strings for localization: \(current.localization)", file: file, line: line) - return acc - } - - return acc.union(Set(keys)) - } - } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift index 2d2ebe4..f9e496b 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift @@ -12,56 +12,16 @@ class FeedPresenterTests: XCTestCase { XCTAssertEqual(FeedPresenter.title, localized("FEED_VIEW_TITLE")) } - func test_init_doesNotSendMessagesToView() { - let (_, view) = makeSUT() - - XCTAssertTrue(view.messages.isEmpty, "Expected no view messages") - } - - func test_didStartLoadingFeed_displaysNoErrorMessageAndStartsLoading() { - let (sut, view) = makeSUT() - - sut.didStartLoadingFeed() - - XCTAssertEqual(view.messages, [ - .display(errorMessage: .none), - .display(isLoading: true) - ]) - } - - func test_didFinishLoadingFeed_displaysFeedAndStopsLoading() { - let (sut, view) = makeSUT() + func test_map_createsViewModel() { let feed = uniqueImageFeed().models - sut.didFinishLoadingFeed(with: feed) - - XCTAssertEqual(view.messages, [ - .display(feed: feed), - .display(isLoading: false) - ]) - } - - func test_didFinishLoadingFeedWithError_displaysLocalizedErrorMessageAndStopsLoading() { - let (sut, view) = makeSUT() - - sut.didFinishLoadingFeed(with: anyNSError()) + let viewModel = FeedPresenter.map(feed) - XCTAssertEqual(view.messages, [ - .display(errorMessage: localized("FEED_VIEW_CONNECTION_ERROR")), - .display(isLoading: false) - ]) + XCTAssertEqual(viewModel.feed, feed) } // MARK: - Helpers - private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: FeedPresenter, view: ViewSpy) { - let view = ViewSpy() - let sut = FeedPresenter(feedView: view, loadingView: view, errorView: view) - trackForMemoryLeaks(view, file: file, line: line) - trackForMemoryLeaks(sut, file: file, line: line) - return (sut, view) - } - private func localized(_ key: String, file: StaticString = #file, line: UInt = #line) -> String { let table = "Feed" let bundle = Bundle(for: FeedPresenter.self) @@ -72,26 +32,4 @@ class FeedPresenterTests: XCTestCase { return value } - private class ViewSpy: FeedView, FeedLoadingView, FeedErrorView { - enum Message: Hashable { - case display(errorMessage: String?) - case display(isLoading: Bool) - case display(feed: [FeedImage]) - } - - private(set) var messages = Set() - - func display(_ viewModel: FeedErrorViewModel) { - messages.insert(.display(errorMessage: viewModel.message)) - } - - func display(_ viewModel: FeedLoadingViewModel) { - messages.insert(.display(isLoading: viewModel.isLoading)) - } - - func display(_ viewModel: FeedViewModel) { - messages.insert(.display(feed: viewModel.feed)) - } - } - } diff --git a/EssentialFeed/EssentialFeedTests/Helpers/SharedLocalizationTestHelpers.swift b/EssentialFeed/EssentialFeedTests/Helpers/SharedLocalizationTestHelpers.swift new file mode 100644 index 0000000..67e9504 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Helpers/SharedLocalizationTestHelpers.swift @@ -0,0 +1,54 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest + +func assertLocalizedKeyAndValuesExist(in presentationBundle: Bundle, _ table: String, file: StaticString = #file, line: UInt = #line) { + let localizationBundles = allLocalizationBundles(in: presentationBundle, file: file, line: line) + let localizedStringKeys = allLocalizedStringKeys(in: localizationBundles, table: table, file: file, line: line) + + localizationBundles.forEach { (bundle, localization) in + localizedStringKeys.forEach { key in + let localizedString = bundle.localizedString(forKey: key, value: nil, table: table) + + if localizedString == key { + let language = Locale.current.localizedString(forLanguageCode: localization) ?? "" + + XCTFail("Missing \(language) (\(localization)) localized string for key: '\(key)' in table: '\(table)'", file: file, line: line) + } + } + } +} + +private typealias LocalizedBundle = (bundle: Bundle, localization: String) + +private func allLocalizationBundles(in bundle: Bundle, file: StaticString = #file, line: UInt = #line) -> [LocalizedBundle] { + return bundle.localizations.compactMap { localization in + guard + let path = bundle.path(forResource: localization, ofType: "lproj"), + let localizedBundle = Bundle(path: path) + else { + XCTFail("Couldn't find bundle for localization: \(localization)", file: file, line: line) + return nil + } + + return (localizedBundle, localization) + } +} + +private func allLocalizedStringKeys(in bundles: [LocalizedBundle], table: String, file: StaticString = #file, line: UInt = #line) -> Set { + return bundles.reduce([]) { (acc, current) in + guard + let path = current.bundle.path(forResource: table, ofType: "strings"), + let strings = NSDictionary(contentsOfFile: path), + let keys = strings.allKeys as? [String] + else { + XCTFail("Couldn't load localized strings for localization: \(current.localization)", file: file, line: line) + return acc + } + + return acc.union(Set(keys)) + } +} diff --git a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift new file mode 100644 index 0000000..a594b64 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift @@ -0,0 +1,115 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeed + +class LoadResourcePresenterTests: XCTestCase { + + func test_init_doesNotSendMessagesToView() { + let (_, view) = makeSUT() + + XCTAssertTrue(view.messages.isEmpty, "Expected no view messages") + } + + func test_didStartLoading_displaysNoErrorMessageAndStartsLoading() { + let (sut, view) = makeSUT() + + sut.didStartLoading() + + XCTAssertEqual(view.messages, [ + .display(errorMessage: .none), + .display(isLoading: true) + ]) + } + + func test_didFinishLoadingResource_displaysResourceAndStopsLoading() { + let (sut, view) = makeSUT(mapper: { resource in + resource + " view model" + }) + + sut.didFinishLoading(with: "resource") + + XCTAssertEqual(view.messages, [ + .display(resourceViewModel: "resource view model"), + .display(isLoading: false) + ]) + } + + func test_didFinishLoadingWithMapperError_displaysLocalizedErrorMessageAndStopsLoading() { + let (sut, view) = makeSUT(mapper: { resource in + throw anyNSError() + }) + + sut.didFinishLoading(with: "resource") + + XCTAssertEqual(view.messages, [ + .display(errorMessage: localized("GENERIC_CONNECTION_ERROR")), + .display(isLoading: false) + ]) + } + + func test_didFinishLoadingWithError_displaysLocalizedErrorMessageAndStopsLoading() { + let (sut, view) = makeSUT() + + sut.didFinishLoading(with: anyNSError()) + + XCTAssertEqual(view.messages, [ + .display(errorMessage: localized("GENERIC_CONNECTION_ERROR")), + .display(isLoading: false) + ]) + } + + // MARK: - Helpers + + private typealias SUT = LoadResourcePresenter + + private func makeSUT( + mapper: @escaping SUT.Mapper = { _ in "any" }, + file: StaticString = #file, + line: UInt = #line + ) -> (sut: SUT, view: ViewSpy) { + let view = ViewSpy() + let sut = SUT(resourceView: view, loadingView: view, errorView: view, mapper: mapper) + trackForMemoryLeaks(view, file: file, line: line) + trackForMemoryLeaks(sut, file: file, line: line) + return (sut, view) + } + + private func localized(_ key: String, file: StaticString = #file, line: UInt = #line) -> String { + let table = "Shared" + let bundle = Bundle(for: SUT.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 + } + + private class ViewSpy: ResourceView, ResourceLoadingView, ResourceErrorView { + typealias ResourceViewModel = String + + enum Message: Hashable { + case display(errorMessage: String?) + case display(isLoading: Bool) + case display(resourceViewModel: String) + } + + private(set) var messages = Set() + + func display(_ viewModel: ResourceErrorViewModel) { + messages.insert(.display(errorMessage: viewModel.message)) + } + + func display(_ viewModel: ResourceLoadingViewModel) { + messages.insert(.display(isLoading: viewModel.isLoading)) + } + + func display(_ viewModel: String) { + messages.insert(.display(resourceViewModel: viewModel)) + } + } + +} diff --git a/EssentialFeed/EssentialFeedTests/Shared Presentation/SharedLocalizationTests.swift b/EssentialFeed/EssentialFeedTests/Shared Presentation/SharedLocalizationTests.swift new file mode 100644 index 0000000..d847899 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Shared Presentation/SharedLocalizationTests.swift @@ -0,0 +1,22 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeed + +final class SharedLocalizationTests: XCTestCase { + + func test_localizedStrings_haveKeysAndValuesForAllSupportedLocalizations() { + let table = "Shared" + let bundle = Bundle(for: LoadResourcePresenter.self) + + assertLocalizedKeyAndValuesExist(in: bundle, table) + } + + private class DummyView: ResourceView { + func display(_ viewModel: Any) {} + } + +} diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift index c18cb59..880b991 100644 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift @@ -11,11 +11,15 @@ public protocol FeedImageCellControllerDelegate { func didCancelImageRequest() } -public final class FeedImageCellController: FeedImageView { +public final class FeedImageCellController: ResourceView, ResourceLoadingView, ResourceErrorView { + public typealias ResourceViewModel = UIImage + + private let viewModel: FeedImageViewModel private let delegate: FeedImageCellControllerDelegate private var cell: FeedImageCell? - public init(delegate: FeedImageCellControllerDelegate) { + public init(viewModel: FeedImageViewModel, delegate: FeedImageCellControllerDelegate) { + self.viewModel = viewModel self.delegate = delegate } @@ -24,6 +28,11 @@ public final class FeedImageCellController: FeedImageView { cell?.onReuse = { [weak self] in self?.releaseCellForReuse() } + cell?.locationContainer.isHidden = !viewModel.hasLocation + cell?.locationLabel.text = viewModel.location + cell?.descriptionLabel.text = viewModel.description + cell?.feedImageView.image = nil + cell?.onRetry = delegate.didRequestImage delegate.didRequestImage() return cell! } @@ -37,14 +46,16 @@ public final class FeedImageCellController: FeedImageView { delegate.didCancelImageRequest() } - public func display(_ viewModel: FeedImageViewModel) { - cell?.locationContainer.isHidden = !viewModel.hasLocation - cell?.locationLabel.text = viewModel.location - cell?.descriptionLabel.text = viewModel.description - cell?.feedImageView.setImageAnimated(viewModel.image) + public func display(_ viewModel: UIImage) { + cell?.feedImageView.setImageAnimated(viewModel) + } + + public func display(_ viewModel: ResourceLoadingViewModel) { cell?.feedImageContainer.isShimmering = viewModel.isLoading - cell?.feedImageRetryButton.isHidden = !viewModel.shouldRetry - cell?.onRetry = delegate.didRequestImage + } + + public func display(_ viewModel: ResourceErrorViewModel) { + cell?.feedImageRetryButton.isHidden = viewModel.message == nil } private func releaseCellForReuse() { diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift index 4e0e034..2a989d5 100644 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift @@ -10,7 +10,7 @@ public protocol FeedViewControllerDelegate { func didRequestFeedRefresh() } -public final class FeedViewController: UITableViewController, UITableViewDataSourcePrefetching, FeedLoadingView, FeedErrorView { +public final class FeedViewController: UITableViewController, UITableViewDataSourcePrefetching, ResourceLoadingView, ResourceErrorView { @IBOutlet private(set) public var errorView: ErrorView? private var loadingControllers = [IndexPath: FeedImageCellController]() @@ -49,11 +49,11 @@ public final class FeedViewController: UITableViewController, UITableViewDataSou tableModel = cellControllers } - public func display(_ viewModel: FeedLoadingViewModel) { + public func display(_ viewModel: ResourceLoadingViewModel) { refreshControl?.update(isRefreshing: viewModel.isLoading) } - public func display(_ viewModel: FeedErrorViewModel) { + public func display(_ viewModel: ResourceErrorViewModel) { errorView?.message = viewModel.message } diff --git a/EssentialFeed/EssentialFeediOSTests/FeedSnapshotTests.swift b/EssentialFeed/EssentialFeediOSTests/FeedSnapshotTests.swift index 9168566..98d140f 100644 --- a/EssentialFeed/EssentialFeediOSTests/FeedSnapshotTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/FeedSnapshotTests.swift @@ -96,7 +96,7 @@ class FeedSnapshotTests: XCTestCase { private extension FeedViewController { func display(_ stubs: [ImageStub]) { let cells: [FeedImageCellController] = stubs.map { stub in - let cellController = FeedImageCellController(delegate: stub) + let cellController = FeedImageCellController(viewModel: stub.viewModel, delegate: stub) stub.controller = cellController return cellController } @@ -106,20 +106,26 @@ private extension FeedViewController { } private class ImageStub: FeedImageCellControllerDelegate { - let viewModel: FeedImageViewModel + let viewModel: FeedImageViewModel + let image: UIImage? weak var controller: FeedImageCellController? init(description: String?, location: String?, image: UIImage?) { - viewModel = FeedImageViewModel( + self.viewModel = FeedImageViewModel( description: description, - location: location, - image: image, - isLoading: false, - shouldRetry: image == nil) + location: location) + self.image = image } func didRequestImage() { - controller?.display(viewModel) + controller?.display(ResourceLoadingViewModel(isLoading: false)) + + if let image = image { + controller?.display(image) + controller?.display(ResourceErrorViewModel(message: .none)) + } else { + controller?.display(ResourceErrorViewModel(message: "any")) + } } func didCancelImageRequest() {}