From 6753975bfedcebd22e1bfbf9e39b5e6a7e392a63 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Fri, 14 Mar 2025 07:31:52 -0300 Subject: [PATCH 01/21] Duplicate FeedPresenter as LoadResourcePresenter --- .../EssentialFeed.xcodeproj/project.pbxproj | 24 +++++ .../LoadResourcePresenter.swift | 49 ++++++++++ .../LoadResourcePresenterTests.swift | 97 +++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift create mode 100644 EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 717b06b..9de2ddd 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -39,6 +39,8 @@ 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 */; }; 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 */; }; @@ -182,6 +184,8 @@ 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 = ""; }; 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 = ""; }; @@ -355,6 +359,7 @@ isa = PBXGroup; children = ( 5B107DFB2BF5BB2100927709 /* EssentialFeed.h */, + 5B73492C2D843BA4007F7D5D /* Shared Presentation */, 5B73491A2D8255FF007F7D5D /* Shared API */, 5B73491B2D825610007F7D5D /* Shared API Infra */, 5B7349172D824BF6007F7D5D /* Image Comments Feature */, @@ -372,6 +377,7 @@ children = ( 5B1C4F9C2C056BB6003F0429 /* EssentialFeed.xctestplan */, 5B8BB9972C02714D00D40D42 /* Helpers */, + 5B7349292D8439FF007F7D5D /* Shared Presentation */, 5B73491E2D825711007F7D5D /* Shared API Infra */, 5B7349222D8258A5007F7D5D /* Image Comments API */, 5B0E220E2BFE404F009FC3EB /* Feed API */, @@ -506,6 +512,22 @@ path = "Image Comments API"; sourceTree = ""; }; + 5B7349292D8439FF007F7D5D /* Shared Presentation */ = { + isa = PBXGroup; + children = ( + 5B73492A2D843A4F007F7D5D /* LoadResourcePresenterTests.swift */, + ); + path = "Shared Presentation"; + sourceTree = ""; + }; + 5B73492C2D843BA4007F7D5D /* Shared Presentation */ = { + isa = PBXGroup; + children = ( + 5B73492D2D843BBE007F7D5D /* LoadResourcePresenter.swift */, + ); + path = "Shared Presentation"; + sourceTree = ""; + }; 5B74FD1B2D64A6DE007478DC /* Helpers */ = { isa = PBXGroup; children = ( @@ -909,6 +931,7 @@ 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 */, @@ -954,6 +977,7 @@ 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 */, diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift new file mode 100644 index 0000000..c21edc1 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift @@ -0,0 +1,49 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation + +public final class LoadResourcePresenter { + 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 init(feedView: FeedView, loadingView: FeedLoadingView, errorView: FeedErrorView) { + self.feedView = feedView + self.loadingView = loadingView + self.errorView = errorView + } + + public static var title: String { + return NSLocalizedString( + "FEED_VIEW_TITLE", + tableName: "Feed", + bundle: Bundle(for: FeedPresenter.self), + comment: "Title for the feed view") + } + + 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)) + } +} diff --git a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift new file mode 100644 index 0000000..0682039 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift @@ -0,0 +1,97 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeed + +class LoadResourcePresenterTests: XCTestCase { + + func test_title_isLocalized() { + XCTAssertEqual(LoadResourcePresenter.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() + 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()) + + XCTAssertEqual(view.messages, [ + .display(errorMessage: localized("FEED_VIEW_CONNECTION_ERROR")), + .display(isLoading: false) + ]) + } + + // MARK: - Helpers + + private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: LoadResourcePresenter, view: ViewSpy) { + let view = ViewSpy() + let sut = LoadResourcePresenter(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: LoadResourcePresenter.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: 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)) + } + } + +} From 0ad2f37bd4d9ec92f777575ec8f5158df9103e85 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Fri, 14 Mar 2025 07:34:57 -0300 Subject: [PATCH 02/21] Remove title from generic presenter since it's specific to each presenter --- .../Shared Presentation/LoadResourcePresenter.swift | 8 -------- .../Shared Presentation/LoadResourcePresenterTests.swift | 4 ---- 2 files changed, 12 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift index c21edc1..ffdea20 100644 --- a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift +++ b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift @@ -24,14 +24,6 @@ public final class LoadResourcePresenter { self.errorView = errorView } - public static var title: String { - return NSLocalizedString( - "FEED_VIEW_TITLE", - tableName: "Feed", - bundle: Bundle(for: FeedPresenter.self), - comment: "Title for the feed view") - } - public func didStartLoadingFeed() { errorView.display(.noError) loadingView.display(FeedLoadingViewModel(isLoading: true)) diff --git a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift index 0682039..7485795 100644 --- a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift @@ -8,10 +8,6 @@ import EssentialFeed class LoadResourcePresenterTests: XCTestCase { - func test_title_isLocalized() { - XCTAssertEqual(LoadResourcePresenter.title, localized("FEED_VIEW_TITLE")) - } - func test_init_doesNotSendMessagesToView() { let (_, view) = makeSUT() From 769ffe748ed6ee9157617a38f69908b51643dd47 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Fri, 14 Mar 2025 07:36:24 -0300 Subject: [PATCH 03/21] Rename method --- .../Shared Presentation/LoadResourcePresenter.swift | 2 +- .../Shared Presentation/LoadResourcePresenterTests.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift index ffdea20..b57c580 100644 --- a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift +++ b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift @@ -24,7 +24,7 @@ public final class LoadResourcePresenter { self.errorView = errorView } - public func didStartLoadingFeed() { + public func didStartLoading() { errorView.display(.noError) loadingView.display(FeedLoadingViewModel(isLoading: true)) } diff --git a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift index 7485795..4b14180 100644 --- a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift @@ -14,10 +14,10 @@ class LoadResourcePresenterTests: XCTestCase { XCTAssertTrue(view.messages.isEmpty, "Expected no view messages") } - func test_didStartLoadingFeed_displaysNoErrorMessageAndStartsLoading() { + func test_didStartLoading_displaysNoErrorMessageAndStartsLoading() { let (sut, view) = makeSUT() - sut.didStartLoadingFeed() + sut.didStartLoading() XCTAssertEqual(view.messages, [ .display(errorMessage: .none), From 7c8a5c7c5495df049e816b16da5f9e855fbe13fb Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Fri, 14 Mar 2025 08:04:58 -0300 Subject: [PATCH 04/21] Displays mapped resource on successful resource loading --- .../LoadResourcePresenter.swift | 18 +++++++++---- .../LoadResourcePresenterTests.swift | 27 +++++++++++-------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift index b57c580..0070453 100644 --- a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift +++ b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift @@ -5,10 +5,17 @@ import Foundation +public protocol ResourceView { + func display(_ viewModel: String) +} + public final class LoadResourcePresenter { - private let feedView: FeedView + public typealias Mapper = (String) -> String + + private let resourceView: ResourceView private let loadingView: FeedLoadingView private let errorView: FeedErrorView + private let mapper: Mapper private var feedLoadError: String { return NSLocalizedString( @@ -18,10 +25,11 @@ public final class LoadResourcePresenter { comment: "Error message displayed when we can't load the image feed from the server") } - public init(feedView: FeedView, loadingView: FeedLoadingView, errorView: FeedErrorView) { - self.feedView = feedView + public init(resourceView: ResourceView, loadingView: FeedLoadingView, errorView: FeedErrorView, mapper: @escaping Mapper) { + self.resourceView = resourceView self.loadingView = loadingView self.errorView = errorView + self.mapper = mapper } public func didStartLoading() { @@ -29,8 +37,8 @@ public final class LoadResourcePresenter { loadingView.display(FeedLoadingViewModel(isLoading: true)) } - public func didFinishLoadingFeed(with feed: [FeedImage]) { - feedView.display(FeedViewModel(feed: feed)) + public func didFinishLoading(with resource: String) { + resourceView.display(mapper(resource)) loadingView.display(FeedLoadingViewModel(isLoading: false)) } diff --git a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift index 4b14180..ec6b009 100644 --- a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift @@ -25,14 +25,15 @@ class LoadResourcePresenterTests: XCTestCase { ]) } - func test_didFinishLoadingFeed_displaysFeedAndStopsLoading() { - let (sut, view) = makeSUT() - let feed = uniqueImageFeed().models + func test_didFinishLoadingResource_displaysResourceAndStopsLoading() { + let (sut, view) = makeSUT(mapper: { resource in + resource + " view model" + }) - sut.didFinishLoadingFeed(with: feed) + sut.didFinishLoading(with: "resource") XCTAssertEqual(view.messages, [ - .display(feed: feed), + .display(resourceViewModel: "resource view model"), .display(isLoading: false) ]) } @@ -50,9 +51,13 @@ class LoadResourcePresenterTests: XCTestCase { // MARK: - Helpers - private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: LoadResourcePresenter, view: ViewSpy) { + private func makeSUT( + mapper: @escaping LoadResourcePresenter.Mapper = { _ in "any" }, + file: StaticString = #file, + line: UInt = #line + ) -> (sut: LoadResourcePresenter, view: ViewSpy) { let view = ViewSpy() - let sut = LoadResourcePresenter(feedView: view, loadingView: view, errorView: view) + let sut = LoadResourcePresenter(resourceView: view, loadingView: view, errorView: view, mapper: mapper) trackForMemoryLeaks(view, file: file, line: line) trackForMemoryLeaks(sut, file: file, line: line) return (sut, view) @@ -68,11 +73,11 @@ class LoadResourcePresenterTests: XCTestCase { return value } - private class ViewSpy: FeedView, FeedLoadingView, FeedErrorView { + private class ViewSpy: ResourceView, FeedLoadingView, FeedErrorView { enum Message: Hashable { case display(errorMessage: String?) case display(isLoading: Bool) - case display(feed: [FeedImage]) + case display(resourceViewModel: String) } private(set) var messages = Set() @@ -85,8 +90,8 @@ class LoadResourcePresenterTests: XCTestCase { messages.insert(.display(isLoading: viewModel.isLoading)) } - func display(_ viewModel: FeedViewModel) { - messages.insert(.display(feed: viewModel.feed)) + func display(_ viewModel: String) { + messages.insert(.display(resourceViewModel: viewModel)) } } From c3b777caefb6fcf51e5e9550b308a5b220f243e5 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Fri, 14 Mar 2025 08:15:54 -0300 Subject: [PATCH 05/21] Make LoadResourcePresenter generic over the Resource types --- .../LoadResourcePresenter.swift | 14 ++++++++------ .../LoadResourcePresenterTests.swift | 12 ++++++++---- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift index 0070453..1cf7869 100644 --- a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift +++ b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift @@ -6,13 +6,15 @@ import Foundation public protocol ResourceView { - func display(_ viewModel: String) + associatedtype ResourceViewModel + + func display(_ viewModel: ResourceViewModel) } -public final class LoadResourcePresenter { - public typealias Mapper = (String) -> String +public final class LoadResourcePresenter { + public typealias Mapper = (Resource) -> View.ResourceViewModel - private let resourceView: ResourceView + private let resourceView: View private let loadingView: FeedLoadingView private let errorView: FeedErrorView private let mapper: Mapper @@ -25,7 +27,7 @@ public final class LoadResourcePresenter { comment: "Error message displayed when we can't load the image feed from the server") } - public init(resourceView: ResourceView, loadingView: FeedLoadingView, errorView: FeedErrorView, mapper: @escaping Mapper) { + public init(resourceView: View, loadingView: FeedLoadingView, errorView: FeedErrorView, mapper: @escaping Mapper) { self.resourceView = resourceView self.loadingView = loadingView self.errorView = errorView @@ -37,7 +39,7 @@ public final class LoadResourcePresenter { loadingView.display(FeedLoadingViewModel(isLoading: true)) } - public func didFinishLoading(with resource: String) { + public func didFinishLoading(with resource: Resource) { resourceView.display(mapper(resource)) loadingView.display(FeedLoadingViewModel(isLoading: false)) } diff --git a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift index ec6b009..a486012 100644 --- a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift @@ -51,13 +51,15 @@ class LoadResourcePresenterTests: XCTestCase { // MARK: - Helpers + private typealias SUT = LoadResourcePresenter + private func makeSUT( - mapper: @escaping LoadResourcePresenter.Mapper = { _ in "any" }, + mapper: @escaping SUT.Mapper = { _ in "any" }, file: StaticString = #file, line: UInt = #line - ) -> (sut: LoadResourcePresenter, view: ViewSpy) { + ) -> (sut: SUT, view: ViewSpy) { let view = ViewSpy() - let sut = LoadResourcePresenter(resourceView: view, loadingView: view, errorView: view, mapper: mapper) + 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) @@ -65,7 +67,7 @@ class LoadResourcePresenterTests: XCTestCase { private func localized(_ key: String, file: StaticString = #file, line: UInt = #line) -> String { let table = "Feed" - let bundle = Bundle(for: LoadResourcePresenter.self) + 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) @@ -74,6 +76,8 @@ class LoadResourcePresenterTests: XCTestCase { } private class ViewSpy: ResourceView, FeedLoadingView, FeedErrorView { + typealias ResourceViewModel = String + enum Message: Hashable { case display(errorMessage: String?) case display(isLoading: Bool) From 2208bfb7b6564f8a02d0e548a433b8c422307f7d Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Fri, 14 Mar 2025 08:28:44 -0300 Subject: [PATCH 06/21] Replace "FEED_VIEW_CONNECTION_ERROR" key with "GENERIC_CONNECTION_ERROR" --- .../FeedUIIntegrationTests.swift | 2 +- .../Feed Presentation/Feed.xcstrings | 20 +++++++++---------- .../Feed Presentation/FeedPresenter.swift | 2 +- .../LoadResourcePresenter.swift | 4 ++-- .../FeedPresenterTests.swift | 2 +- .../LoadResourcePresenterTests.swift | 6 +++--- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift index 2865e92..49bbd98 100644 --- a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift @@ -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, localized("GENERIC_CONNECTION_ERROR")) sut.simulateUserInitiatedFeedReload() XCTAssertEqual(sut.errorMessage, nil) diff --git a/EssentialFeed/EssentialFeed/Feed Presentation/Feed.xcstrings b/EssentialFeed/EssentialFeed/Feed Presentation/Feed.xcstrings index f309d60..c21d61c 100644 --- a/EssentialFeed/EssentialFeed/Feed Presentation/Feed.xcstrings +++ b/EssentialFeed/EssentialFeed/Feed Presentation/Feed.xcstrings @@ -1,50 +1,50 @@ { "sourceLanguage" : "en", "strings" : { - "FEED_VIEW_CONNECTION_ERROR" : { - "comment" : "Error message displayed when we can't load the image feed from the server", + "FEED_VIEW_TITLE" : { + "comment" : "Title for the feed view", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Couldn't connect to server" + "value" : "My Feed" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "No se pudo conectar al servidor" + "value" : "Mi Feed" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Não foi possível conectar ao servidor" + "value" : "Meu Feed" } } } }, - "FEED_VIEW_TITLE" : { - "comment" : "Title for the feed view", + "GENERIC_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" : "My Feed" + "value" : "Couldn't connect to server" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Mi Feed" + "value" : "No se pudo conectar al servidor" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Meu Feed" + "value" : "Não foi possível conectar ao servidor" } } } diff --git a/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift b/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift index 178cc7b..a02e818 100644 --- a/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift +++ b/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift @@ -22,7 +22,7 @@ public final class FeedPresenter { private var feedLoadError: String { return NSLocalizedString( - "FEED_VIEW_CONNECTION_ERROR", + "GENERIC_CONNECTION_ERROR", tableName: "Feed", bundle: Bundle(for: FeedPresenter.self), comment: "Error message displayed when we can't load the image feed from the server") diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift index 1cf7869..b4102e2 100644 --- a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift +++ b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift @@ -21,7 +21,7 @@ public final class LoadResourcePresenter { private var feedLoadError: String { return NSLocalizedString( - "FEED_VIEW_CONNECTION_ERROR", + "GENERIC_CONNECTION_ERROR", tableName: "Feed", bundle: Bundle(for: FeedPresenter.self), comment: "Error message displayed when we can't load the image feed from the server") @@ -44,7 +44,7 @@ public final class LoadResourcePresenter { loadingView.display(FeedLoadingViewModel(isLoading: false)) } - public func didFinishLoadingFeed(with error: Error) { + public func didFinishLoading(with error: Error) { errorView.display(.error(message: feedLoadError)) loadingView.display(FeedLoadingViewModel(isLoading: false)) } diff --git a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift index 2d2ebe4..2f46a0f 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift @@ -47,7 +47,7 @@ class FeedPresenterTests: XCTestCase { sut.didFinishLoadingFeed(with: anyNSError()) XCTAssertEqual(view.messages, [ - .display(errorMessage: localized("FEED_VIEW_CONNECTION_ERROR")), + .display(errorMessage: localized("GENERIC_CONNECTION_ERROR")), .display(isLoading: false) ]) } diff --git a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift index a486012..6296689 100644 --- a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift @@ -38,13 +38,13 @@ class LoadResourcePresenterTests: XCTestCase { ]) } - func test_didFinishLoadingFeedWithError_displaysLocalizedErrorMessageAndStopsLoading() { + func test_didFinishLoadingWithError_displaysLocalizedErrorMessageAndStopsLoading() { let (sut, view) = makeSUT() - sut.didFinishLoadingFeed(with: anyNSError()) + sut.didFinishLoading(with: anyNSError()) XCTAssertEqual(view.messages, [ - .display(errorMessage: localized("FEED_VIEW_CONNECTION_ERROR")), + .display(errorMessage: localized("GENERIC_CONNECTION_ERROR")), .display(isLoading: false) ]) } From 18afb6c4cdb9456950fcc7916c6f166d623199a7 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Fri, 14 Mar 2025 19:09:21 -0300 Subject: [PATCH 07/21] Move "GENERIC_CONNECTION_ERROR" localization key to new Shared.strings --- .../FeedUIIntegrationTests.swift | 4 +- .../FeedUIIntegrationTests+Localization.swift | 18 ++--- .../EssentialFeed.xcodeproj/project.pbxproj | 8 +++ .../Feed Presentation/Feed.xcstrings | 24 ------- .../Feed Presentation/FeedPresenter.swift | 2 +- .../LoadResourcePresenter.swift | 12 ++-- .../Shared Presentation/Shared.xcstrings | 30 +++++++++ .../FeedPresenterTests.swift | 5 +- .../LoadResourcePresenterTests.swift | 2 +- .../SharedLocalizationTests.swift | 67 +++++++++++++++++++ 10 files changed, 127 insertions(+), 45 deletions(-) create mode 100644 EssentialFeed/EssentialFeed/Shared Presentation/Shared.xcstrings create mode 100644 EssentialFeed/EssentialFeedTests/Shared Presentation/SharedLocalizationTests.swift diff --git a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift index 49bbd98..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("GENERIC_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 9de2ddd..4051141 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -41,6 +41,8 @@ 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 */; }; 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 */; }; @@ -186,6 +188,8 @@ 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 = ""; }; 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 = ""; }; @@ -516,6 +520,7 @@ isa = PBXGroup; children = ( 5B73492A2D843A4F007F7D5D /* LoadResourcePresenterTests.swift */, + 5B7349342D844D6D007F7D5D /* SharedLocalizationTests.swift */, ); path = "Shared Presentation"; sourceTree = ""; @@ -524,6 +529,7 @@ isa = PBXGroup; children = ( 5B73492D2D843BBE007F7D5D /* LoadResourcePresenter.swift */, + 5B7349322D844B03007F7D5D /* Shared.xcstrings */, ); path = "Shared Presentation"; sourceTree = ""; @@ -869,6 +875,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5B7349332D844B03007F7D5D /* Shared.xcstrings in Resources */, 5B8829042D6A7527006E0BD7 /* Feed.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -971,6 +978,7 @@ 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 */, diff --git a/EssentialFeed/EssentialFeed/Feed Presentation/Feed.xcstrings b/EssentialFeed/EssentialFeed/Feed Presentation/Feed.xcstrings index c21d61c..d7e4481 100644 --- a/EssentialFeed/EssentialFeed/Feed Presentation/Feed.xcstrings +++ b/EssentialFeed/EssentialFeed/Feed Presentation/Feed.xcstrings @@ -24,30 +24,6 @@ } } } - }, - "GENERIC_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" - } - } - } } }, "version" : "1.0" diff --git a/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift b/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift index a02e818..63d6282 100644 --- a/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift +++ b/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift @@ -23,7 +23,7 @@ public final class FeedPresenter { private var feedLoadError: String { return NSLocalizedString( "GENERIC_CONNECTION_ERROR", - tableName: "Feed", + tableName: "Shared", bundle: Bundle(for: FeedPresenter.self), comment: "Error message displayed when we can't load the image feed from the server") } diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift index b4102e2..856cfa8 100644 --- a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift +++ b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift @@ -19,12 +19,12 @@ public final class LoadResourcePresenter { private let errorView: FeedErrorView private let mapper: Mapper - private var feedLoadError: String { - return NSLocalizedString( + public static var loadError: String { + NSLocalizedString( "GENERIC_CONNECTION_ERROR", - tableName: "Feed", - bundle: Bundle(for: FeedPresenter.self), - comment: "Error message displayed when we can't load the image feed from the server") + 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: FeedLoadingView, errorView: FeedErrorView, mapper: @escaping Mapper) { @@ -45,7 +45,7 @@ public final class LoadResourcePresenter { } public func didFinishLoading(with error: Error) { - errorView.display(.error(message: feedLoadError)) + errorView.display(.error(message: Self.loadError)) loadingView.display(FeedLoadingViewModel(isLoading: false)) } } 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/FeedPresenterTests.swift b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift index 2f46a0f..005af64 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift @@ -47,7 +47,7 @@ class FeedPresenterTests: XCTestCase { sut.didFinishLoadingFeed(with: anyNSError()) XCTAssertEqual(view.messages, [ - .display(errorMessage: localized("GENERIC_CONNECTION_ERROR")), + .display(errorMessage: localized("GENERIC_CONNECTION_ERROR", table: "Shared")), .display(isLoading: false) ]) } @@ -62,8 +62,7 @@ class FeedPresenterTests: XCTestCase { return (sut, view) } - private func localized(_ key: String, file: StaticString = #file, line: UInt = #line) -> String { - let table = "Feed" + private func localized(_ key: String, table: String = "Feed", file: StaticString = #file, line: UInt = #line) -> String { let bundle = Bundle(for: FeedPresenter.self) let value = bundle.localizedString(forKey: key, value: nil, table: table) if value == key { diff --git a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift index 6296689..455ef4e 100644 --- a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift @@ -66,7 +66,7 @@ class LoadResourcePresenterTests: XCTestCase { } private func localized(_ key: String, file: StaticString = #file, line: UInt = #line) -> String { - let table = "Feed" + let table = "Shared" let bundle = Bundle(for: SUT.self) let value = bundle.localizedString(forKey: key, value: nil, table: table) if value == key { diff --git a/EssentialFeed/EssentialFeedTests/Shared Presentation/SharedLocalizationTests.swift b/EssentialFeed/EssentialFeedTests/Shared Presentation/SharedLocalizationTests.swift new file mode 100644 index 0000000..75a06ef --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Shared Presentation/SharedLocalizationTests.swift @@ -0,0 +1,67 @@ +// +// 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 presentationBundle = Bundle(for: LoadResourcePresenter.self) + let localizationBundles = allLocalizationBundles(in: presentationBundle) + let localizedStringKeys = allLocalizedStringKeys(in: localizationBundles, table: table) + + 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)'") + } + } + } + } + + private class DummyView: ResourceView { + func display(_ viewModel: Any) {} + } + + // 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)) + } + } +} + From cdd6e749997fda3429e0a4309bdb42b54b9679ea Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Fri, 14 Mar 2025 19:20:54 -0300 Subject: [PATCH 08/21] Remove duplication in the localization tests --- .../EssentialFeed.xcodeproj/project.pbxproj | 10 ++++ .../FeedLocalizationTests.swift | 48 +---------------- .../SharedLocalizationTestHelpers.swift | 54 +++++++++++++++++++ .../SharedLocalizationTests.swift | 49 +---------------- 4 files changed, 68 insertions(+), 93 deletions(-) create mode 100644 EssentialFeed/EssentialFeedTests/Helpers/SharedLocalizationTestHelpers.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 4051141..14636a6 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -43,6 +43,10 @@ 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 */; }; 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 */; }; @@ -190,6 +194,7 @@ 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 = ""; }; 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 = ""; }; @@ -601,6 +606,7 @@ children = ( 5B8BB9982C02719F00D40D42 /* XCTestCase+MemoryLeakTracking.swift */, 5B034B442CA3A1A100FB65F8 /* SharedTestHelpers.swift */, + 5B7349362D84E167007F7D5D /* SharedLocalizationTestHelpers.swift */, ); path = Helpers; sourceTree = ""; @@ -963,6 +969,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 */, @@ -997,6 +1004,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 */, ); @@ -1023,6 +1031,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 */, @@ -1035,6 +1044,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/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/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/SharedLocalizationTests.swift b/EssentialFeed/EssentialFeedTests/Shared Presentation/SharedLocalizationTests.swift index 75a06ef..d847899 100644 --- a/EssentialFeed/EssentialFeedTests/Shared Presentation/SharedLocalizationTests.swift +++ b/EssentialFeed/EssentialFeedTests/Shared Presentation/SharedLocalizationTests.swift @@ -10,58 +10,13 @@ final class SharedLocalizationTests: XCTestCase { func test_localizedStrings_haveKeysAndValuesForAllSupportedLocalizations() { let table = "Shared" - let presentationBundle = Bundle(for: LoadResourcePresenter.self) - let localizationBundles = allLocalizationBundles(in: presentationBundle) - let localizedStringKeys = allLocalizedStringKeys(in: localizationBundles, table: table) + let bundle = Bundle(for: LoadResourcePresenter.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) } private class DummyView: ResourceView { func display(_ viewModel: Any) {} } - // 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)) - } - } } - From 729614c912b82ccac2cc18b402b1f0e533025390 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Fri, 14 Mar 2025 19:31:41 -0300 Subject: [PATCH 09/21] Rename and move ResourceLoadingView and VIewModel to Shared module --- .../EssentialApp/WeakRefVirtualProxy.swift | 4 ++-- .../EssentialFeed.xcodeproj/project.pbxproj | 12 ++++++++---- .../Feed Presentation/FeedPresenter.swift | 14 +++++--------- .../LoadResourcePresenter.swift | 10 +++++----- .../Shared Presentation/ResourceLoadingView.swift | 10 ++++++++++ .../ResourceLoadingViewModel.swift} | 2 +- .../Feed Presentation/FeedPresenterTests.swift | 4 ++-- .../LoadResourcePresenterTests.swift | 4 ++-- .../Feed UI/Controllers/FeedViewController.swift | 4 ++-- 9 files changed, 37 insertions(+), 27 deletions(-) create mode 100644 EssentialFeed/EssentialFeed/Shared Presentation/ResourceLoadingView.swift rename EssentialFeed/EssentialFeed/{Feed Presentation/FeedLoadingViewModel.swift => Shared Presentation/ResourceLoadingViewModel.swift} (74%) diff --git a/EssentialApp/EssentialApp/WeakRefVirtualProxy.swift b/EssentialApp/EssentialApp/WeakRefVirtualProxy.swift index 594dbde..bb3a5cc 100644 --- a/EssentialApp/EssentialApp/WeakRefVirtualProxy.swift +++ b/EssentialApp/EssentialApp/WeakRefVirtualProxy.swift @@ -21,8 +21,8 @@ extension WeakRefVirtualProxy: FeedErrorView where T: FeedErrorView { } } -extension WeakRefVirtualProxy: FeedLoadingView where T: FeedLoadingView { - func display(_ viewModel: FeedLoadingViewModel) { +extension WeakRefVirtualProxy: ResourceLoadingView where T: ResourceLoadingView { + func display(_ viewModel: ResourceLoadingViewModel) { object?.display(viewModel) } } diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 14636a6..59c1136 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ 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 */; }; 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 */; }; @@ -54,7 +55,7 @@ 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 */; }; + 5B8829082D6A7B12006E0BD7 /* ResourceLoadingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829072D6A7B12006E0BD7 /* ResourceLoadingViewModel.swift */; }; 5B88290A2D6A7B76006E0BD7 /* FeedErrorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829092D6A7B76006E0BD7 /* FeedErrorViewModel.swift */; }; 5B88290B2D6A8133006E0BD7 /* FeedLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8AB3732D5ECCBF00CDDDEB /* FeedLocalizationTests.swift */; }; 5B88290D2D6A82D3006E0BD7 /* FeedImagePresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B88290C2D6A82D3006E0BD7 /* FeedImagePresenterTests.swift */; }; @@ -195,6 +196,7 @@ 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 = ""; }; 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 = ""; }; @@ -202,7 +204,7 @@ 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 = ""; }; + 5B8829072D6A7B12006E0BD7 /* ResourceLoadingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceLoadingViewModel.swift; sourceTree = ""; }; 5B8829092D6A7B76006E0BD7 /* FeedErrorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedErrorViewModel.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 = ""; }; @@ -534,6 +536,8 @@ isa = PBXGroup; children = ( 5B73492D2D843BBE007F7D5D /* LoadResourcePresenter.swift */, + 5B73493B2D84E463007F7D5D /* ResourceLoadingView.swift */, + 5B8829072D6A7B12006E0BD7 /* ResourceLoadingViewModel.swift */, 5B7349322D844B03007F7D5D /* Shared.xcstrings */, ); path = "Shared Presentation"; @@ -577,7 +581,6 @@ 5B8829022D6A7401006E0BD7 /* FeedPresenter.swift */, 5B88290E2D6A94C3006E0BD7 /* FeedImagePresenter.swift */, 5B8829052D6A7A9A006E0BD7 /* FeedViewModel.swift */, - 5B8829072D6A7B12006E0BD7 /* FeedLoadingViewModel.swift */, 5B8829092D6A7B76006E0BD7 /* FeedErrorViewModel.swift */, 5B8829102D6A964F006E0BD7 /* FeedImageViewModel.swift */, 5B8828FF2D6A7137006E0BD7 /* Feed.xcstrings */, @@ -951,12 +954,13 @@ 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 */, 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 */, diff --git a/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift b/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift index 63d6282..1eed269 100644 --- a/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift +++ b/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift @@ -7,17 +7,13 @@ public protocol FeedView { func display(_ viewModel: FeedViewModel) } -public protocol FeedLoadingView { - func display(_ viewModel: FeedLoadingViewModel) -} - public protocol FeedErrorView { func display(_ viewModel: FeedErrorViewModel) } public final class FeedPresenter { private let feedView: FeedView - private let loadingView: FeedLoadingView + private let loadingView: ResourceLoadingView private let errorView: FeedErrorView private var feedLoadError: String { @@ -36,7 +32,7 @@ public final class FeedPresenter { comment: "Title for the feed view") } - public init(feedView: FeedView, loadingView: FeedLoadingView, errorView: FeedErrorView) { + public init(feedView: FeedView, loadingView: ResourceLoadingView, errorView: FeedErrorView) { self.feedView = feedView self.loadingView = loadingView self.errorView = errorView @@ -44,16 +40,16 @@ public final class FeedPresenter { public func didStartLoadingFeed() { errorView.display(.noError) - loadingView.display(FeedLoadingViewModel(isLoading: true)) + loadingView.display(ResourceLoadingViewModel(isLoading: true)) } public func didFinishLoadingFeed(with feed: [FeedImage]) { feedView.display(FeedViewModel(feed: feed)) - loadingView.display(FeedLoadingViewModel(isLoading: false)) + loadingView.display(ResourceLoadingViewModel(isLoading: false)) } public func didFinishLoadingFeed(with error: Error) { errorView.display(.error(message: feedLoadError)) - loadingView.display(FeedLoadingViewModel(isLoading: false)) + loadingView.display(ResourceLoadingViewModel(isLoading: false)) } } diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift index 856cfa8..79b9a63 100644 --- a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift +++ b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift @@ -15,7 +15,7 @@ public final class LoadResourcePresenter { public typealias Mapper = (Resource) -> View.ResourceViewModel private let resourceView: View - private let loadingView: FeedLoadingView + private let loadingView: ResourceLoadingView private let errorView: FeedErrorView private let mapper: Mapper @@ -27,7 +27,7 @@ public final class LoadResourcePresenter { comment: "Error message displayed when we can't load the resource from the server") } - public init(resourceView: View, loadingView: FeedLoadingView, errorView: FeedErrorView, mapper: @escaping Mapper) { + public init(resourceView: View, loadingView: ResourceLoadingView, errorView: FeedErrorView, mapper: @escaping Mapper) { self.resourceView = resourceView self.loadingView = loadingView self.errorView = errorView @@ -36,16 +36,16 @@ public final class LoadResourcePresenter { public func didStartLoading() { errorView.display(.noError) - loadingView.display(FeedLoadingViewModel(isLoading: true)) + loadingView.display(ResourceLoadingViewModel(isLoading: true)) } public func didFinishLoading(with resource: Resource) { resourceView.display(mapper(resource)) - loadingView.display(FeedLoadingViewModel(isLoading: false)) + loadingView.display(ResourceLoadingViewModel(isLoading: false)) } public func didFinishLoading(with error: Error) { errorView.display(.error(message: Self.loadError)) - loadingView.display(FeedLoadingViewModel(isLoading: false)) + loadingView.display(ResourceLoadingViewModel(isLoading: false)) } } 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/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift index 005af64..4705e2c 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift @@ -71,7 +71,7 @@ class FeedPresenterTests: XCTestCase { return value } - private class ViewSpy: FeedView, FeedLoadingView, FeedErrorView { + private class ViewSpy: FeedView, ResourceLoadingView, FeedErrorView { enum Message: Hashable { case display(errorMessage: String?) case display(isLoading: Bool) @@ -84,7 +84,7 @@ class FeedPresenterTests: XCTestCase { messages.insert(.display(errorMessage: viewModel.message)) } - func display(_ viewModel: FeedLoadingViewModel) { + func display(_ viewModel: ResourceLoadingViewModel) { messages.insert(.display(isLoading: viewModel.isLoading)) } diff --git a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift index 455ef4e..8c7f36c 100644 --- a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift @@ -75,7 +75,7 @@ class LoadResourcePresenterTests: XCTestCase { return value } - private class ViewSpy: ResourceView, FeedLoadingView, FeedErrorView { + private class ViewSpy: ResourceView, ResourceLoadingView, FeedErrorView { typealias ResourceViewModel = String enum Message: Hashable { @@ -90,7 +90,7 @@ class LoadResourcePresenterTests: XCTestCase { messages.insert(.display(errorMessage: viewModel.message)) } - func display(_ viewModel: FeedLoadingViewModel) { + func display(_ viewModel: ResourceLoadingViewModel) { messages.insert(.display(isLoading: viewModel.isLoading)) } diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift index 4e0e034..c5dd006 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, FeedErrorView { @IBOutlet private(set) public var errorView: ErrorView? private var loadingControllers = [IndexPath: FeedImageCellController]() @@ -49,7 +49,7 @@ public final class FeedViewController: UITableViewController, UITableViewDataSou tableModel = cellControllers } - public func display(_ viewModel: FeedLoadingViewModel) { + public func display(_ viewModel: ResourceLoadingViewModel) { refreshControl?.update(isRefreshing: viewModel.isLoading) } From 75d7efb26df3aa9b96e55dc122d52791e342d4d2 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Fri, 14 Mar 2025 21:01:04 -0300 Subject: [PATCH 10/21] Rename and move ResourceErrorView and ViewModel to Shared module --- .../EssentialApp/WeakRefVirtualProxy.swift | 4 ++-- .../EssentialFeed.xcodeproj/project.pbxproj | 12 ++++++++---- .../Feed Presentation/FeedErrorViewModel.swift | 16 ---------------- .../Feed Presentation/FeedPresenter.swift | 8 ++------ .../LoadResourcePresenter.swift | 4 ++-- .../Shared Presentation/ResourceErrorView.swift | 10 ++++++++++ .../ResourceErrorViewModel.swift | 16 ++++++++++++++++ .../Feed Presentation/FeedPresenterTests.swift | 4 ++-- .../LoadResourcePresenterTests.swift | 4 ++-- .../Feed UI/Controllers/FeedViewController.swift | 4 ++-- 10 files changed, 46 insertions(+), 36 deletions(-) delete mode 100644 EssentialFeed/EssentialFeed/Feed Presentation/FeedErrorViewModel.swift create mode 100644 EssentialFeed/EssentialFeed/Shared Presentation/ResourceErrorView.swift create mode 100644 EssentialFeed/EssentialFeed/Shared Presentation/ResourceErrorViewModel.swift diff --git a/EssentialApp/EssentialApp/WeakRefVirtualProxy.swift b/EssentialApp/EssentialApp/WeakRefVirtualProxy.swift index bb3a5cc..d0a07fd 100644 --- a/EssentialApp/EssentialApp/WeakRefVirtualProxy.swift +++ b/EssentialApp/EssentialApp/WeakRefVirtualProxy.swift @@ -15,8 +15,8 @@ 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) } } diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 59c1136..93af5c0 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -48,6 +48,7 @@ 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 */; }; @@ -56,7 +57,7 @@ 5B8829042D6A7527006E0BD7 /* Feed.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5B8828FF2D6A7137006E0BD7 /* Feed.xcstrings */; }; 5B8829062D6A7A9A006E0BD7 /* FeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829052D6A7A9A006E0BD7 /* FeedViewModel.swift */; }; 5B8829082D6A7B12006E0BD7 /* ResourceLoadingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829072D6A7B12006E0BD7 /* ResourceLoadingViewModel.swift */; }; - 5B88290A2D6A7B76006E0BD7 /* FeedErrorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829092D6A7B76006E0BD7 /* FeedErrorViewModel.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 */; }; @@ -197,6 +198,7 @@ 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 = ""; }; @@ -205,7 +207,7 @@ 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 /* ResourceLoadingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceLoadingViewModel.swift; sourceTree = ""; }; - 5B8829092D6A7B76006E0BD7 /* FeedErrorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedErrorViewModel.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 = ""; }; @@ -538,6 +540,8 @@ 5B73492D2D843BBE007F7D5D /* LoadResourcePresenter.swift */, 5B73493B2D84E463007F7D5D /* ResourceLoadingView.swift */, 5B8829072D6A7B12006E0BD7 /* ResourceLoadingViewModel.swift */, + 5B73493D2D84FA14007F7D5D /* ResourceErrorView.swift */, + 5B8829092D6A7B76006E0BD7 /* ResourceErrorViewModel.swift */, 5B7349322D844B03007F7D5D /* Shared.xcstrings */, ); path = "Shared Presentation"; @@ -581,7 +585,6 @@ 5B8829022D6A7401006E0BD7 /* FeedPresenter.swift */, 5B88290E2D6A94C3006E0BD7 /* FeedImagePresenter.swift */, 5B8829052D6A7A9A006E0BD7 /* FeedViewModel.swift */, - 5B8829092D6A7B76006E0BD7 /* FeedErrorViewModel.swift */, 5B8829102D6A964F006E0BD7 /* FeedImageViewModel.swift */, 5B8828FF2D6A7137006E0BD7 /* Feed.xcstrings */, ); @@ -937,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 */, @@ -958,7 +962,7 @@ 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 /* ResourceLoadingViewModel.swift in Sources */, 5BDE3C652D6C19D8005D520D /* CoreDataFeedStore+FeedImageDataLoader.swift in Sources */, 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/FeedPresenter.swift b/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift index 1eed269..7d309fb 100644 --- a/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift +++ b/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift @@ -7,14 +7,10 @@ public protocol FeedView { func display(_ viewModel: FeedViewModel) } -public protocol FeedErrorView { - func display(_ viewModel: FeedErrorViewModel) -} - public final class FeedPresenter { private let feedView: FeedView private let loadingView: ResourceLoadingView - private let errorView: FeedErrorView + private let errorView: ResourceErrorView private var feedLoadError: String { return NSLocalizedString( @@ -32,7 +28,7 @@ public final class FeedPresenter { comment: "Title for the feed view") } - public init(feedView: FeedView, loadingView: ResourceLoadingView, errorView: FeedErrorView) { + public init(feedView: FeedView, loadingView: ResourceLoadingView, errorView: ResourceErrorView) { self.feedView = feedView self.loadingView = loadingView self.errorView = errorView diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift index 79b9a63..e4e519d 100644 --- a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift +++ b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift @@ -16,7 +16,7 @@ public final class LoadResourcePresenter { private let resourceView: View private let loadingView: ResourceLoadingView - private let errorView: FeedErrorView + private let errorView: ResourceErrorView private let mapper: Mapper public static var loadError: String { @@ -27,7 +27,7 @@ public final class LoadResourcePresenter { comment: "Error message displayed when we can't load the resource from the server") } - public init(resourceView: View, loadingView: ResourceLoadingView, errorView: FeedErrorView, mapper: @escaping Mapper) { + public init(resourceView: View, loadingView: ResourceLoadingView, errorView: ResourceErrorView, mapper: @escaping Mapper) { self.resourceView = resourceView self.loadingView = loadingView self.errorView = errorView 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/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift index 4705e2c..b476861 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift @@ -71,7 +71,7 @@ class FeedPresenterTests: XCTestCase { return value } - private class ViewSpy: FeedView, ResourceLoadingView, FeedErrorView { + private class ViewSpy: FeedView, ResourceLoadingView, ResourceErrorView { enum Message: Hashable { case display(errorMessage: String?) case display(isLoading: Bool) @@ -80,7 +80,7 @@ class FeedPresenterTests: XCTestCase { private(set) var messages = Set() - func display(_ viewModel: FeedErrorViewModel) { + func display(_ viewModel: ResourceErrorViewModel) { messages.insert(.display(errorMessage: viewModel.message)) } diff --git a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift index 8c7f36c..f466857 100644 --- a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift @@ -75,7 +75,7 @@ class LoadResourcePresenterTests: XCTestCase { return value } - private class ViewSpy: ResourceView, ResourceLoadingView, FeedErrorView { + private class ViewSpy: ResourceView, ResourceLoadingView, ResourceErrorView { typealias ResourceViewModel = String enum Message: Hashable { @@ -86,7 +86,7 @@ class LoadResourcePresenterTests: XCTestCase { private(set) var messages = Set() - func display(_ viewModel: FeedErrorViewModel) { + func display(_ viewModel: ResourceErrorViewModel) { messages.insert(.display(errorMessage: viewModel.message)) } diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift index c5dd006..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, ResourceLoadingView, FeedErrorView { +public final class FeedViewController: UITableViewController, UITableViewDataSourcePrefetching, ResourceLoadingView, ResourceErrorView { @IBOutlet private(set) public var errorView: ErrorView? private var loadingControllers = [IndexPath: FeedImageCellController]() @@ -53,7 +53,7 @@ public final class FeedViewController: UITableViewController, UITableViewDataSou refreshControl?.update(isRefreshing: viewModel.isLoading) } - public func display(_ viewModel: FeedErrorViewModel) { + public func display(_ viewModel: ResourceErrorViewModel) { errorView?.message = viewModel.message } From 053534448ddafd828b192806f35a0dc4ed998b8e Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Fri, 14 Mar 2025 21:08:50 -0300 Subject: [PATCH 11/21] Add FeedPresenter map --- .../EssentialFeed/Feed Presentation/FeedPresenter.swift | 6 +++++- .../Feed Presentation/FeedPresenterTests.swift | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift b/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift index 7d309fb..be112f3 100644 --- a/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift +++ b/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift @@ -40,7 +40,7 @@ public final class FeedPresenter { } public func didFinishLoadingFeed(with feed: [FeedImage]) { - feedView.display(FeedViewModel(feed: feed)) + feedView.display(Self.map(feed)) loadingView.display(ResourceLoadingViewModel(isLoading: false)) } @@ -48,4 +48,8 @@ public final class FeedPresenter { errorView.display(.error(message: feedLoadError)) loadingView.display(ResourceLoadingViewModel(isLoading: false)) } + + public static func map(_ feed: [FeedImage]) -> FeedViewModel { + FeedViewModel(feed: feed) + } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift index b476861..5067d11 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift @@ -12,6 +12,14 @@ class FeedPresenterTests: XCTestCase { XCTAssertEqual(FeedPresenter.title, localized("FEED_VIEW_TITLE")) } + func test_map_createsViewModel() { + let feed = uniqueImageFeed().models + + let viewModel = FeedPresenter.map(feed) + + XCTAssertEqual(viewModel.feed, feed) + } + func test_init_doesNotSendMessagesToView() { let (_, view) = makeSUT() From f3530e2d2d9db365675cc4e533264ff862390643 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Fri, 14 Mar 2025 21:37:00 -0300 Subject: [PATCH 12/21] Replace FeedPresenter with generic presenter --- .../EssentialApp/FeedLoaderPresentationAdapter.swift | 8 ++++---- EssentialApp/EssentialApp/FeedUIComposer.swift | 7 ++++--- EssentialApp/EssentialApp/FeedViewAdapter.swift | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift b/EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift index 0b5e9f2..8fd6bbd 100644 --- a/EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift +++ b/EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift @@ -10,14 +10,14 @@ import EssentialFeediOS final class FeedLoaderPresentationAdapter: FeedViewControllerDelegate { private let feedLoader: () -> AnyPublisher<[FeedImage], Error> private var cancellable: Cancellable? - var presenter: FeedPresenter? + var presenter: LoadResourcePresenter<[FeedImage], FeedViewAdapter>? init(feedLoader: @escaping () -> AnyPublisher<[FeedImage], Error>) { self.feedLoader = feedLoader } func didRequestFeedRefresh() { - presenter?.didStartLoadingFeed() + presenter?.didStartLoading() cancellable = feedLoader() .dispatchOnMainQueue() @@ -27,10 +27,10 @@ final class FeedLoaderPresentationAdapter: FeedViewControllerDelegate { case .finished: break case let .failure(error): - self?.presenter?.didFinishLoadingFeed(with: error) + self?.presenter?.didFinishLoading(with: error) } }, receiveValue: { [weak self] feed in - self?.presenter?.didFinishLoadingFeed(with: feed) + self?.presenter?.didFinishLoading(with: feed) }) } } diff --git a/EssentialApp/EssentialApp/FeedUIComposer.swift b/EssentialApp/EssentialApp/FeedUIComposer.swift index 0dc27f1..a248d61 100644 --- a/EssentialApp/EssentialApp/FeedUIComposer.swift +++ b/EssentialApp/EssentialApp/FeedUIComposer.swift @@ -21,12 +21,13 @@ public final class FeedUIComposer { 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..0a624b9 100644 --- a/EssentialApp/EssentialApp/FeedViewAdapter.swift +++ b/EssentialApp/EssentialApp/FeedViewAdapter.swift @@ -7,7 +7,7 @@ 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 From ccb605fd10735903974cf85716ffb2ac7302de42 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Fri, 14 Mar 2025 21:46:49 -0300 Subject: [PATCH 13/21] Remove unused FeedPresenter logic --- .../Feed Presentation/FeedPresenter.swift | 39 +---------- .../FeedPresenterTests.swift | 70 ------------------- 2 files changed, 2 insertions(+), 107 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift b/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift index be112f3..b8f6900 100644 --- a/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift +++ b/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift @@ -3,52 +3,17 @@ // Copyright © 2025 PortoCode. All Rights Reserved. // -public protocol FeedView { - func display(_ viewModel: FeedViewModel) -} +import Foundation public final class FeedPresenter { - private let feedView: FeedView - private let loadingView: ResourceLoadingView - private let errorView: ResourceErrorView - - private var feedLoadError: String { - return NSLocalizedString( - "GENERIC_CONNECTION_ERROR", - tableName: "Shared", - 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: ResourceLoadingView, errorView: ResourceErrorView) { - self.feedView = feedView - self.loadingView = loadingView - self.errorView = errorView - } - - public func didStartLoadingFeed() { - errorView.display(.noError) - loadingView.display(ResourceLoadingViewModel(isLoading: true)) - } - - public func didFinishLoadingFeed(with feed: [FeedImage]) { - feedView.display(Self.map(feed)) - loadingView.display(ResourceLoadingViewModel(isLoading: false)) - } - - public func didFinishLoadingFeed(with error: Error) { - errorView.display(.error(message: feedLoadError)) - loadingView.display(ResourceLoadingViewModel(isLoading: false)) - } - public static func map(_ feed: [FeedImage]) -> FeedViewModel { FeedViewModel(feed: feed) } diff --git a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift index 5067d11..f89c1c5 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift @@ -20,56 +20,8 @@ class FeedPresenterTests: XCTestCase { XCTAssertEqual(viewModel.feed, feed) } - 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() - 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()) - - XCTAssertEqual(view.messages, [ - .display(errorMessage: localized("GENERIC_CONNECTION_ERROR", table: "Shared")), - .display(isLoading: false) - ]) - } - // 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, table: String = "Feed", file: StaticString = #file, line: UInt = #line) -> String { let bundle = Bundle(for: FeedPresenter.self) let value = bundle.localizedString(forKey: key, value: nil, table: table) @@ -79,26 +31,4 @@ class FeedPresenterTests: XCTestCase { return value } - private class ViewSpy: FeedView, ResourceLoadingView, ResourceErrorView { - enum Message: Hashable { - case display(errorMessage: String?) - case display(isLoading: Bool) - case display(feed: [FeedImage]) - } - - 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: FeedViewModel) { - messages.insert(.display(feed: viewModel.feed)) - } - } - } From 48025c54f4893f98763c1752d8169a0531dee0b7 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Fri, 14 Mar 2025 21:48:57 -0300 Subject: [PATCH 14/21] Inline param --- .../Feed Presentation/FeedPresenterTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift index f89c1c5..f9e496b 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift @@ -22,7 +22,8 @@ class FeedPresenterTests: XCTestCase { // MARK: - Helpers - private func localized(_ key: String, table: String = "Feed", file: StaticString = #file, line: UInt = #line) -> String { + private 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 { From 923e056416014c481322a4618134199b90d37d0c Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Fri, 14 Mar 2025 23:01:05 -0300 Subject: [PATCH 15/21] Make Presentation Adapter generic so it can be reused --- .../EssentialApp/FeedUIComposer.swift | 2 +- ... => LoadResourcePresentationAdapter.swift} | 24 ++++++++++++------- 2 files changed, 16 insertions(+), 10 deletions(-) rename EssentialApp/EssentialApp/{FeedLoaderPresentationAdapter.swift => LoadResourcePresentationAdapter.swift} (54%) diff --git a/EssentialApp/EssentialApp/FeedUIComposer.swift b/EssentialApp/EssentialApp/FeedUIComposer.swift index a248d61..f012f90 100644 --- a/EssentialApp/EssentialApp/FeedUIComposer.swift +++ b/EssentialApp/EssentialApp/FeedUIComposer.swift @@ -15,7 +15,7 @@ public final class FeedUIComposer { feedLoader: @escaping () -> AnyPublisher<[FeedImage], Error>, imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher ) -> FeedViewController { - let presentationAdapter = FeedLoaderPresentationAdapter(feedLoader: feedLoader) + let presentationAdapter = LoadResourcePresentationAdapter<[FeedImage], FeedViewAdapter>(loader: feedLoader) let feedController = makeFeedViewController( delegate: presentationAdapter, diff --git a/EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift b/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift similarity index 54% rename from EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift rename to EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift index 8fd6bbd..d18ef2a 100644 --- a/EssentialApp/EssentialApp/FeedLoaderPresentationAdapter.swift +++ b/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift @@ -7,19 +7,19 @@ import Combine import EssentialFeed import EssentialFeediOS -final class FeedLoaderPresentationAdapter: FeedViewControllerDelegate { - private let feedLoader: () -> AnyPublisher<[FeedImage], Error> +final class LoadResourcePresentationAdapter { + private let loader: () -> AnyPublisher private var cancellable: Cancellable? - var presenter: LoadResourcePresenter<[FeedImage], FeedViewAdapter>? + var presenter: LoadResourcePresenter? - init(feedLoader: @escaping () -> AnyPublisher<[FeedImage], Error>) { - self.feedLoader = feedLoader + init(loader: @escaping () -> AnyPublisher) { + self.loader = loader } - func didRequestFeedRefresh() { + func loadResource() { presenter?.didStartLoading() - cancellable = feedLoader() + cancellable = loader() .dispatchOnMainQueue() .sink( receiveCompletion: { [weak self] completion in @@ -29,8 +29,14 @@ final class FeedLoaderPresentationAdapter: FeedViewControllerDelegate { case let .failure(error): self?.presenter?.didFinishLoading(with: error) } - }, receiveValue: { [weak self] feed in - self?.presenter?.didFinishLoading(with: feed) + }, receiveValue: { [weak self] resource in + self?.presenter?.didFinishLoading(with: resource) }) } } + +extension LoadResourcePresentationAdapter: FeedViewControllerDelegate { + func didRequestFeedRefresh() { + loadResource() + } +} From 4792a1d8ac18dce26b3596e55eaf1789cb087ad4 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Fri, 14 Mar 2025 23:16:54 -0300 Subject: [PATCH 16/21] Add FeedImagePresenter map --- .../Feed Presentation/FeedImagePresenter.swift | 9 +++++++++ .../Feed Presentation/FeedImagePresenterTests.swift | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/EssentialFeed/EssentialFeed/Feed Presentation/FeedImagePresenter.swift b/EssentialFeed/EssentialFeed/Feed Presentation/FeedImagePresenter.swift index b8a16ca..9473d71 100644 --- a/EssentialFeed/EssentialFeed/Feed Presentation/FeedImagePresenter.swift +++ b/EssentialFeed/EssentialFeed/Feed Presentation/FeedImagePresenter.swift @@ -47,4 +47,13 @@ public class FeedImagePresenter where View.Image == isLoading: false, shouldRetry: true)) } + + public static func map(_ image: FeedImage) -> FeedImageViewModel { + FeedImageViewModel( + description: image.description, + location: image.location, + image: nil, + isLoading: false, + shouldRetry: false) + } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift index d8bb209..9feda08 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift @@ -8,6 +8,15 @@ import EssentialFeed class FeedImagePresenterTests: XCTestCase { + func test_map_createsViewModel() { + let image = uniqueImage() + + let viewModel = FeedImagePresenter.map(image) + + XCTAssertEqual(viewModel.description, image.description) + XCTAssertEqual(viewModel.location, image.location) + } + func test_init_doesNotSendMessagesToView() { let (_, view) = makeSUT() From b520129c544eacd8e58aca7ce6514318fe82a029 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Fri, 14 Mar 2025 23:22:20 -0300 Subject: [PATCH 17/21] Display error on mapper error --- .../Shared Presentation/LoadResourcePresenter.swift | 10 +++++++--- .../LoadResourcePresenterTests.swift | 13 +++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift index e4e519d..2cdbeaf 100644 --- a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift +++ b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift @@ -12,7 +12,7 @@ public protocol ResourceView { } public final class LoadResourcePresenter { - public typealias Mapper = (Resource) -> View.ResourceViewModel + public typealias Mapper = (Resource) throws -> View.ResourceViewModel private let resourceView: View private let loadingView: ResourceLoadingView @@ -40,8 +40,12 @@ public final class LoadResourcePresenter { } public func didFinishLoading(with resource: Resource) { - resourceView.display(mapper(resource)) - loadingView.display(ResourceLoadingViewModel(isLoading: false)) + do { + resourceView.display(try mapper(resource)) + loadingView.display(ResourceLoadingViewModel(isLoading: false)) + } catch { + didFinishLoading(with: error) + } } public func didFinishLoading(with error: Error) { diff --git a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift index f466857..a594b64 100644 --- a/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Shared Presentation/LoadResourcePresenterTests.swift @@ -38,6 +38,19 @@ class LoadResourcePresenterTests: XCTestCase { ]) } + 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() From b7d85d2a7396c6820e9510c1833d160b9b00e7c2 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Sat, 15 Mar 2025 00:12:24 -0300 Subject: [PATCH 18/21] Replace FeedImagePresenter with LoadResourcePresenter --- .../EssentialApp/FeedViewAdapter.swift | 25 ++++++++++++--- .../LoadResourcePresentationAdapter.swift | 11 +++++++ .../EssentialApp/WeakRefVirtualProxy.swift | 4 +-- .../Controllers/FeedImageCellController.swift | 31 +++++++++++++------ 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/EssentialApp/EssentialApp/FeedViewAdapter.swift b/EssentialApp/EssentialApp/FeedViewAdapter.swift index 0a624b9..b2e21e3 100644 --- a/EssentialApp/EssentialApp/FeedViewAdapter.swift +++ b/EssentialApp/EssentialApp/FeedViewAdapter.swift @@ -18,14 +18,29 @@ final class FeedViewAdapter: ResourceView { 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 = LoadResourcePresentationAdapter>( + loader: { [imageLoader] in + imageLoader(model.url) + }) - adapter.presenter = FeedImagePresenter( - view: WeakRefVirtualProxy(view), - imageTransformer: UIImage.init) + let view = FeedImageCellController( + viewModel: FeedImagePresenter.map(model), + delegate: adapter) + + adapter.presenter = LoadResourcePresenter( + resourceView: WeakRefVirtualProxy(view), + loadingView: WeakRefVirtualProxy(view), + errorView: WeakRefVirtualProxy(view), + mapper: { data in + guard let image = UIImage(data: data) else { + throw InvalidImageData() + } + return image + }) return view }) } } + +private struct InvalidImageData: Error {} diff --git a/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift b/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift index d18ef2a..77af513 100644 --- a/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift +++ b/EssentialApp/EssentialApp/LoadResourcePresentationAdapter.swift @@ -40,3 +40,14 @@ extension LoadResourcePresentationAdapter: FeedViewControllerDelegate { 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 d0a07fd..2028f7f 100644 --- a/EssentialApp/EssentialApp/WeakRefVirtualProxy.swift +++ b/EssentialApp/EssentialApp/WeakRefVirtualProxy.swift @@ -27,8 +27,8 @@ extension WeakRefVirtualProxy: ResourceLoadingView where T: ResourceLoadingView } } -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/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift index c18cb59..c48be3b 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: FeedImageView, 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,18 @@ 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: FeedImageViewModel) {} + + 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() { From 855a22f8eeef20cdcc3202821e899408d74a9cfe Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Sat, 15 Mar 2025 00:37:01 -0300 Subject: [PATCH 19/21] Remove unused FeedImagePresenter logic --- ...edImageDataLoaderPresentationAdapter.swift | 47 --------- .../EssentialApp/FeedViewAdapter.swift | 2 +- .../FeedImagePresenter.swift | 51 +--------- .../FeedImageViewModel.swift | 5 +- .../FeedImagePresenterTests.swift | 97 +------------------ .../Controllers/FeedImageCellController.swift | 8 +- .../FeedSnapshotTests.swift | 22 +++-- 7 files changed, 23 insertions(+), 209 deletions(-) delete mode 100644 EssentialApp/EssentialApp/FeedImageDataLoaderPresentationAdapter.swift 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/FeedViewAdapter.swift b/EssentialApp/EssentialApp/FeedViewAdapter.swift index b2e21e3..a5f2816 100644 --- a/EssentialApp/EssentialApp/FeedViewAdapter.swift +++ b/EssentialApp/EssentialApp/FeedViewAdapter.swift @@ -24,7 +24,7 @@ final class FeedViewAdapter: ResourceView { }) let view = FeedImageCellController( - viewModel: FeedImagePresenter.map(model), + viewModel: FeedImagePresenter.map(model), delegate: adapter) adapter.presenter = LoadResourcePresenter( diff --git a/EssentialFeed/EssentialFeed/Feed Presentation/FeedImagePresenter.swift b/EssentialFeed/EssentialFeed/Feed Presentation/FeedImagePresenter.swift index 9473d71..37c2de1 100644 --- a/EssentialFeed/EssentialFeed/Feed Presentation/FeedImagePresenter.swift +++ b/EssentialFeed/EssentialFeed/Feed Presentation/FeedImagePresenter.swift @@ -5,55 +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 static func map(_ image: FeedImage) -> FeedImageViewModel { +public class FeedImagePresenter { + public static func map(_ image: FeedImage) -> FeedImageViewModel { FeedImageViewModel( description: image.description, - location: image.location, - image: nil, - isLoading: false, - shouldRetry: false) + 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/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift index 9feda08..3a3afe6 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedImagePresenterTests.swift @@ -11,105 +11,10 @@ class FeedImagePresenterTests: XCTestCase { func test_map_createsViewModel() { let image = uniqueImage() - let viewModel = FeedImagePresenter.map(image) + let viewModel = FeedImagePresenter.map(image) XCTAssertEqual(viewModel.description, image.description) XCTAssertEqual(viewModel.location, image.location) } - 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) - 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]() - - func display(_ model: FeedImageViewModel) { - messages.append(model) - } - } - } diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift index c48be3b..880b991 100644 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift @@ -11,14 +11,14 @@ public protocol FeedImageCellControllerDelegate { func didCancelImageRequest() } -public final class FeedImageCellController: FeedImageView, ResourceView, ResourceLoadingView, ResourceErrorView { +public final class FeedImageCellController: ResourceView, ResourceLoadingView, ResourceErrorView { public typealias ResourceViewModel = UIImage - private let viewModel: FeedImageViewModel + private let viewModel: FeedImageViewModel private let delegate: FeedImageCellControllerDelegate private var cell: FeedImageCell? - public init(viewModel: FeedImageViewModel, delegate: FeedImageCellControllerDelegate) { + public init(viewModel: FeedImageViewModel, delegate: FeedImageCellControllerDelegate) { self.viewModel = viewModel self.delegate = delegate } @@ -46,8 +46,6 @@ public final class FeedImageCellController: FeedImageView, ResourceView, Resourc delegate.didCancelImageRequest() } - public func display(_ viewModel: FeedImageViewModel) {} - public func display(_ viewModel: UIImage) { cell?.feedImageView.setImageAnimated(viewModel) } 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() {} From 83ac9c10931d1170f6b36b21815e4b5eac602188 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Sat, 15 Mar 2025 00:41:49 -0300 Subject: [PATCH 20/21] Add type aliases to shorten type definitions with generics --- EssentialApp/EssentialApp/FeedUIComposer.swift | 4 +++- EssentialApp/EssentialApp/FeedViewAdapter.swift | 9 +++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/EssentialApp/EssentialApp/FeedUIComposer.swift b/EssentialApp/EssentialApp/FeedUIComposer.swift index f012f90..c719870 100644 --- a/EssentialApp/EssentialApp/FeedUIComposer.swift +++ b/EssentialApp/EssentialApp/FeedUIComposer.swift @@ -11,11 +11,13 @@ 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 = LoadResourcePresentationAdapter<[FeedImage], FeedViewAdapter>(loader: feedLoader) + let presentationAdapter = FeedPresentationAdapter(loader: feedLoader) let feedController = makeFeedViewController( delegate: presentationAdapter, diff --git a/EssentialApp/EssentialApp/FeedViewAdapter.swift b/EssentialApp/EssentialApp/FeedViewAdapter.swift index a5f2816..d3a5d75 100644 --- a/EssentialApp/EssentialApp/FeedViewAdapter.swift +++ b/EssentialApp/EssentialApp/FeedViewAdapter.swift @@ -11,6 +11,8 @@ 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,10 +20,9 @@ final class FeedViewAdapter: ResourceView { func display(_ viewModel: FeedViewModel) { controller?.display(viewModel.feed.map { model in - let adapter = LoadResourcePresentationAdapter>( - loader: { [imageLoader] in - imageLoader(model.url) - }) + let adapter = ImageDataPresentationAdapter(loader: { [imageLoader] in + imageLoader(model.url) + }) let view = FeedImageCellController( viewModel: FeedImagePresenter.map(model), From 10283941ea93f69d9ccb17bf511db9cac15ea9f0 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Sat, 15 Mar 2025 00:45:45 -0300 Subject: [PATCH 21/21] Move UIImage creation to a `tryMake` extension --- .../EssentialApp/FeedViewAdapter.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/EssentialApp/EssentialApp/FeedViewAdapter.swift b/EssentialApp/EssentialApp/FeedViewAdapter.swift index d3a5d75..80791fc 100644 --- a/EssentialApp/EssentialApp/FeedViewAdapter.swift +++ b/EssentialApp/EssentialApp/FeedViewAdapter.swift @@ -32,16 +32,20 @@ final class FeedViewAdapter: ResourceView { resourceView: WeakRefVirtualProxy(view), loadingView: WeakRefVirtualProxy(view), errorView: WeakRefVirtualProxy(view), - mapper: { data in - guard let image = UIImage(data: data) else { - throw InvalidImageData() - } - return image - }) + mapper: UIImage.tryMake) return view }) } } -private struct InvalidImageData: Error {} +extension UIImage { + struct InvalidImageData: Error {} + + static func tryMake(data: Data) throws -> UIImage { + guard let image = UIImage(data: data) else { + throw InvalidImageData() + } + return image + } +}