From c18bad5a2b91a413deef329c053cd4215a340471 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 18 Mar 2025 21:35:46 -0300 Subject: [PATCH 01/15] Update Feed API URL --- .../EssentialAppTests/FeedAcceptanceTests.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index 1b16026..63b237d 100644 --- a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -80,12 +80,15 @@ class FeedAcceptanceTests: XCTestCase { } private func makeData(for url: URL) -> Data { - switch url.absoluteString { - case "http://image.com": + switch url.path { + case "/image-1", "/image-2": return makeImageData() - default: + case "/essential-feed/v1/feed": return makeFeedData() + + default: + return Data() } } @@ -95,8 +98,8 @@ class FeedAcceptanceTests: XCTestCase { private func makeFeedData() -> Data { return try! JSONSerialization.data(withJSONObject: ["items": [ - ["id": UUID().uuidString, "image": "http://image.com"], - ["id": UUID().uuidString, "image": "http://image.com"] + ["id": "2AB2AE66-A4B7-4A16-B374-51BBAC8DB086", "image": "http://feed.com/image-1"], + ["id": "A28F5FE3-27A7-44E9-8DF5-53742D0E4A5A", "image": "http://feed.com/image-2"] ]]) } From e127aee493d8d9ec4f125041cd06b4b4b838239e Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 18 Mar 2025 21:41:48 -0300 Subject: [PATCH 02/15] Reorder tests --- .../FeedUIIntegrationTests.swift | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift index 8b9c31c..81c9ba7 100644 --- a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift @@ -105,6 +105,18 @@ final class FeedUIIntegrationTests: XCTestCase { assertThat(sut, isRendering: [image0]) } + func test_loadFeedCompletion_dispatchesFromBackgroundToMainThread() { + let (sut, loader) = makeSUT() + sut.simulateAppearance() + + let exp = expectation(description: "Wait for background queue") + DispatchQueue.global().async { + loader.completeFeedLoading(at: 0) + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + } + func test_loadFeedCompletion_rendersErrorMessageOnErrorUntilNextReload() { let (sut, loader) = makeSUT() @@ -131,6 +143,8 @@ final class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(sut.errorMessage, nil) } + // MARK: - Image View Tests + func test_feedImageView_loadsImageURLWhenVisible() { let image0 = makeImage(url: URL(string: "http://url-0.com")!) let image1 = makeImage(url: URL(string: "http://url-1.com")!) @@ -379,18 +393,6 @@ final class FeedUIIntegrationTests: XCTestCase { XCTAssertNil(view?.renderedImage, "Expected no rendered image when an image load finishes after the view is not visible anymore") } - func test_loadFeedCompletion_dispatchesFromBackgroundToMainThread() { - let (sut, loader) = makeSUT() - sut.simulateAppearance() - - let exp = expectation(description: "Wait for background queue") - DispatchQueue.global().async { - loader.completeFeedLoading(at: 0) - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - } - func test_loadImageDataCompletion_dispatchesFromBackgroundToMainThread() { let (sut, loader) = makeSUT() From 1d5968dd23b9842bf01ea45a5c7d9d43c922bf9a Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 18 Mar 2025 21:47:08 -0300 Subject: [PATCH 03/15] Duplicate FeedUIComposer as CommentsUIComposer --- .../EssentialApp/CommentsUIComposer.swift | 42 +++++ .../CommentsUIIntegrationTests.swift | 160 ++++++++++++++++++ .../FeedUIIntegrationTests.swift | 2 +- 3 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 EssentialApp/EssentialApp/CommentsUIComposer.swift create mode 100644 EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift diff --git a/EssentialApp/EssentialApp/CommentsUIComposer.swift b/EssentialApp/EssentialApp/CommentsUIComposer.swift new file mode 100644 index 0000000..e206694 --- /dev/null +++ b/EssentialApp/EssentialApp/CommentsUIComposer.swift @@ -0,0 +1,42 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import UIKit +import Combine +import EssentialFeed +import EssentialFeediOS + +public final class CommentsUIComposer { + private init() {} + + private typealias FeedPresentationAdapter = LoadResourcePresentationAdapter<[FeedImage], FeedViewAdapter> + + public static func commentsComposedWith( + commentsLoader: @escaping () -> AnyPublisher<[FeedImage], Error> + ) -> ListViewController { + let presentationAdapter = FeedPresentationAdapter(loader: commentsLoader) + + let feedController = makeFeedViewController(title: FeedPresenter.title) + feedController.onRefresh = presentationAdapter.loadResource + + presentationAdapter.presenter = LoadResourcePresenter( + resourceView: FeedViewAdapter( + controller: feedController, + imageLoader: { _ in Empty().eraseToAnyPublisher() }), + loadingView: WeakRefVirtualProxy(feedController), + errorView: WeakRefVirtualProxy(feedController), + mapper: FeedPresenter.map) + + return feedController + } + + private static func makeFeedViewController(title: String) -> ListViewController { + let bundle = Bundle(for: ListViewController.self) + let storyboard = UIStoryboard(name: "Feed", bundle: bundle) + let feedController = storyboard.instantiateInitialViewController() as! ListViewController + feedController.title = title + return feedController + } +} diff --git a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift new file mode 100644 index 0000000..33852f8 --- /dev/null +++ b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift @@ -0,0 +1,160 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import UIKit +import EssentialApp +import EssentialFeed +import EssentialFeediOS + +class CommentsUIIntegrationTests: FeedUIIntegrationTests { + + override func test_feedView_hasTitle() { + let (sut, _) = makeSUT() + + sut.simulateAppearance() + + XCTAssertEqual(sut.title, feedTitle) + } + + override func test_loadFeedActions_requestFeedFromLoader() { + let (sut, loader) = makeSUT() + XCTAssertEqual(loader.loadFeedCallCount, 0, "Expected no loading requests before view appears") + + sut.simulateAppearance() + XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected a loading request once view appears") + + sut.simulateUserInitiatedFeedReload() + XCTAssertEqual(loader.loadFeedCallCount, 2, "Expected another loading request once user initiates a reload") + + sut.simulateUserInitiatedFeedReload() + XCTAssertEqual(loader.loadFeedCallCount, 3, "Expected yet another loading request once user initiates another reload") + } + + override func test_loadFeedActions_runsAutomaticallyOnlyOnFirstAppearance() { + let (sut, loader) = makeSUT() + XCTAssertEqual(loader.loadFeedCallCount, 0, "Expected no loading requests before view appears") + + sut.simulateAppearance() + XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected a loading request once view appears") + + sut.simulateAppearance() + XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected no loading request the second time view appears") + } + + override func test_loadingFeedIndicator_isVisibleWhileLoadingFeed() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + XCTAssertTrue(sut.isShowingLoadingIndicator, "Expected loading indicator once view appears") + + loader.completeFeedLoading(at: 0) + XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once loading completes successfully") + + sut.simulateUserInitiatedFeedReload() + XCTAssertTrue(sut.isShowingLoadingIndicator, "Expected loading indicator once user initiates a reload") + + loader.completeFeedLoadingWithError(at: 1) + XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once user initiated loading completes with error") + } + + override func test_loadFeedCompletion_rendersSuccessfullyLoadedFeed() { + let image0 = makeImage(description: "a description", location: "a location") + let image1 = makeImage(description: nil, location: "another location") + let image2 = makeImage(description: "another description", location: nil) + let image3 = makeImage(description: nil, location: nil) + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + assertThat(sut, isRendering: []) + + loader.completeFeedLoading(with: [image0], at: 0) + assertThat(sut, isRendering: [image0]) + + sut.simulateUserInitiatedFeedReload() + loader.completeFeedLoading(with: [image0, image1, image2, image3], at: 1) + assertThat(sut, isRendering: [image0, image1, image2, image3]) + } + + override func test_loadFeedCompletion_rendersSuccessfullyLoadedEmptyFeedAfterNonEmptyFeed() { + let image0 = makeImage() + let image1 = makeImage() + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [image0, image1], at: 0) + assertThat(sut, isRendering: [image0, image1]) + + sut.simulateUserInitiatedFeedReload() + loader.completeFeedLoading(with: [], at: 1) + assertThat(sut, isRendering: []) + } + + override func test_loadFeedCompletion_doesNotAlterCurrentRenderingStateOnError() { + let image0 = makeImage() + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [image0], at: 0) + assertThat(sut, isRendering: [image0]) + + sut.simulateUserInitiatedFeedReload() + loader.completeFeedLoadingWithError(at: 1) + assertThat(sut, isRendering: [image0]) + } + + override func test_loadFeedCompletion_dispatchesFromBackgroundToMainThread() { + let (sut, loader) = makeSUT() + sut.simulateAppearance() + + let exp = expectation(description: "Wait for background queue") + DispatchQueue.global().async { + loader.completeFeedLoading(at: 0) + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + } + + override func test_loadFeedCompletion_rendersErrorMessageOnErrorUntilNextReload() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + XCTAssertEqual(sut.errorMessage, nil) + + loader.completeFeedLoadingWithError(at: 0) + XCTAssertEqual(sut.errorMessage, loadError) + + sut.simulateUserInitiatedFeedReload() + XCTAssertEqual(sut.errorMessage, nil) + } + + override func test_tapOnErrorView_hidesErrorMessage() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + XCTAssertEqual(sut.errorMessage, nil) + + loader.completeFeedLoadingWithError(at: 0) + XCTAssertEqual(sut.errorMessage, loadError) + + sut.simulateErrorViewTap() + XCTAssertEqual(sut.errorMessage, nil) + } + + // MARK: - Helpers + + private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: ListViewController, loader: LoaderSpy) { + let loader = LoaderSpy() + let sut = CommentsUIComposer.commentsComposedWith(commentsLoader: loader.loadPublisher) + trackForMemoryLeaks(loader, file: file, line: line) + trackForMemoryLeaks(sut, file: file, line: line) + return (sut, loader) + } + + private func makeImage(description: String? = nil, location: String? = nil, url: URL = URL(string: "http://any-url.com")!) -> FeedImage { + return FeedImage(id: UUID(), description: description, location: location, url: url) + } + +} diff --git a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift index 81c9ba7..537ecd0 100644 --- a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift @@ -9,7 +9,7 @@ import EssentialApp import EssentialFeed import EssentialFeediOS -final class FeedUIIntegrationTests: XCTestCase { +class FeedUIIntegrationTests: XCTestCase { func test_feedView_hasTitle() { let (sut, _) = makeSUT() From a5b4a8436ec08becb80688215a26bf186bebfc0a Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 18 Mar 2025 22:30:19 -0300 Subject: [PATCH 04/15] Set comments title --- EssentialApp/EssentialApp/CommentsUIComposer.swift | 2 +- .../EssentialAppTests/CommentsUIIntegrationTests.swift | 4 ++-- .../Helpers/FeedUIIntegrationTests+Localization.swift | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/EssentialApp/EssentialApp/CommentsUIComposer.swift b/EssentialApp/EssentialApp/CommentsUIComposer.swift index e206694..a0e93eb 100644 --- a/EssentialApp/EssentialApp/CommentsUIComposer.swift +++ b/EssentialApp/EssentialApp/CommentsUIComposer.swift @@ -18,7 +18,7 @@ public final class CommentsUIComposer { ) -> ListViewController { let presentationAdapter = FeedPresentationAdapter(loader: commentsLoader) - let feedController = makeFeedViewController(title: FeedPresenter.title) + let feedController = makeFeedViewController(title: ImageCommentsPresenter.title) feedController.onRefresh = presentationAdapter.loadResource presentationAdapter.presenter = LoadResourcePresenter( diff --git a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift index 33852f8..5bceaa1 100644 --- a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift @@ -11,12 +11,12 @@ import EssentialFeediOS class CommentsUIIntegrationTests: FeedUIIntegrationTests { - override func test_feedView_hasTitle() { + func test_commentsView_hasTitle() { let (sut, _) = makeSUT() sut.simulateAppearance() - XCTAssertEqual(sut.title, feedTitle) + XCTAssertEqual(sut.title, commentsTitle) } override func test_loadFeedActions_requestFeedFromLoader() { diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift index d09595d..1d9c6ea 100644 --- a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift +++ b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift @@ -19,4 +19,8 @@ extension FeedUIIntegrationTests { var feedTitle: String { FeedPresenter.title } + + var commentsTitle: String { + ImageCommentsPresenter.title + } } From 4bf42a1459412db5749116b41a4102c5a8581ab2 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 18 Mar 2025 22:43:20 -0300 Subject: [PATCH 05/15] Load actions request comments --- .../CommentsUIIntegrationTests.swift | 56 +++++++++++++------ .../FeedUIIntegrationTests.swift | 14 ++--- .../ListViewController+TestHelpers.swift | 2 +- 3 files changed, 48 insertions(+), 24 deletions(-) diff --git a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift index 5bceaa1..581fbe0 100644 --- a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift @@ -4,6 +4,7 @@ // import XCTest +import Combine import UIKit import EssentialApp import EssentialFeed @@ -19,29 +20,29 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { XCTAssertEqual(sut.title, commentsTitle) } - override func test_loadFeedActions_requestFeedFromLoader() { + func test_loadCommentsActions_requestCommentsFromLoader() { let (sut, loader) = makeSUT() - XCTAssertEqual(loader.loadFeedCallCount, 0, "Expected no loading requests before view appears") + XCTAssertEqual(loader.loadCommentsCallCount, 0, "Expected no loading requests before view appears") sut.simulateAppearance() - XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected a loading request once view appears") + XCTAssertEqual(loader.loadCommentsCallCount, 1, "Expected a loading request once view appears") - sut.simulateUserInitiatedFeedReload() - XCTAssertEqual(loader.loadFeedCallCount, 2, "Expected another loading request once user initiates a reload") + sut.simulateUserInitiatedReload() + XCTAssertEqual(loader.loadCommentsCallCount, 2, "Expected another loading request once user initiates a reload") - sut.simulateUserInitiatedFeedReload() - XCTAssertEqual(loader.loadFeedCallCount, 3, "Expected yet another loading request once user initiates another reload") + sut.simulateUserInitiatedReload() + XCTAssertEqual(loader.loadCommentsCallCount, 3, "Expected yet another loading request once user initiates another reload") } - override func test_loadFeedActions_runsAutomaticallyOnlyOnFirstAppearance() { + func test_loadCommentsActions_runsAutomaticallyOnlyOnFirstAppearance() { let (sut, loader) = makeSUT() - XCTAssertEqual(loader.loadFeedCallCount, 0, "Expected no loading requests before view appears") + XCTAssertEqual(loader.loadCommentsCallCount, 0, "Expected no loading requests before view appears") sut.simulateAppearance() - XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected a loading request once view appears") + XCTAssertEqual(loader.loadCommentsCallCount, 1, "Expected a loading request once view appears") sut.simulateAppearance() - XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected no loading request the second time view appears") + XCTAssertEqual(loader.loadCommentsCallCount, 1, "Expected no loading request the second time view appears") } override func test_loadingFeedIndicator_isVisibleWhileLoadingFeed() { @@ -53,7 +54,7 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { loader.completeFeedLoading(at: 0) XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once loading completes successfully") - sut.simulateUserInitiatedFeedReload() + sut.simulateUserInitiatedReload() XCTAssertTrue(sut.isShowingLoadingIndicator, "Expected loading indicator once user initiates a reload") loader.completeFeedLoadingWithError(at: 1) @@ -73,7 +74,7 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { loader.completeFeedLoading(with: [image0], at: 0) assertThat(sut, isRendering: [image0]) - sut.simulateUserInitiatedFeedReload() + sut.simulateUserInitiatedReload() loader.completeFeedLoading(with: [image0, image1, image2, image3], at: 1) assertThat(sut, isRendering: [image0, image1, image2, image3]) } @@ -87,7 +88,7 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { loader.completeFeedLoading(with: [image0, image1], at: 0) assertThat(sut, isRendering: [image0, image1]) - sut.simulateUserInitiatedFeedReload() + sut.simulateUserInitiatedReload() loader.completeFeedLoading(with: [], at: 1) assertThat(sut, isRendering: []) } @@ -100,7 +101,7 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { loader.completeFeedLoading(with: [image0], at: 0) assertThat(sut, isRendering: [image0]) - sut.simulateUserInitiatedFeedReload() + sut.simulateUserInitiatedReload() loader.completeFeedLoadingWithError(at: 1) assertThat(sut, isRendering: [image0]) } @@ -126,7 +127,7 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { loader.completeFeedLoadingWithError(at: 0) XCTAssertEqual(sut.errorMessage, loadError) - sut.simulateUserInitiatedFeedReload() + sut.simulateUserInitiatedReload() XCTAssertEqual(sut.errorMessage, nil) } @@ -157,4 +158,27 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { return FeedImage(id: UUID(), description: description, location: location, url: url) } + private class LoaderSpy { + private var requests = [PassthroughSubject<[FeedImage], Error>]() + + var loadCommentsCallCount: Int { + return requests.count + } + + func loadPublisher() -> AnyPublisher<[FeedImage], Error> { + let publisher = PassthroughSubject<[FeedImage], Error>() + requests.append(publisher) + return publisher.eraseToAnyPublisher() + } + + func completeFeedLoading(with feed: [FeedImage] = [], at index: Int = 0) { + requests[index].send(feed) + } + + func completeFeedLoadingWithError(at index: Int = 0) { + let error = NSError(domain: "an error", code: 0) + requests[index].send(completion: .failure(error)) + } + } + } diff --git a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift index 537ecd0..98e6a2c 100644 --- a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift @@ -26,10 +26,10 @@ class FeedUIIntegrationTests: XCTestCase { sut.simulateAppearance() XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected a loading request once view appears") - sut.simulateUserInitiatedFeedReload() + sut.simulateUserInitiatedReload() XCTAssertEqual(loader.loadFeedCallCount, 2, "Expected another loading request once user initiates a reload") - sut.simulateUserInitiatedFeedReload() + sut.simulateUserInitiatedReload() XCTAssertEqual(loader.loadFeedCallCount, 3, "Expected yet another loading request once user initiates another reload") } @@ -53,7 +53,7 @@ class FeedUIIntegrationTests: XCTestCase { loader.completeFeedLoading(at: 0) XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once loading completes successfully") - sut.simulateUserInitiatedFeedReload() + sut.simulateUserInitiatedReload() XCTAssertTrue(sut.isShowingLoadingIndicator, "Expected loading indicator once user initiates a reload") loader.completeFeedLoadingWithError(at: 1) @@ -73,7 +73,7 @@ class FeedUIIntegrationTests: XCTestCase { loader.completeFeedLoading(with: [image0], at: 0) assertThat(sut, isRendering: [image0]) - sut.simulateUserInitiatedFeedReload() + sut.simulateUserInitiatedReload() loader.completeFeedLoading(with: [image0, image1, image2, image3], at: 1) assertThat(sut, isRendering: [image0, image1, image2, image3]) } @@ -87,7 +87,7 @@ class FeedUIIntegrationTests: XCTestCase { loader.completeFeedLoading(with: [image0, image1], at: 0) assertThat(sut, isRendering: [image0, image1]) - sut.simulateUserInitiatedFeedReload() + sut.simulateUserInitiatedReload() loader.completeFeedLoading(with: [], at: 1) assertThat(sut, isRendering: []) } @@ -100,7 +100,7 @@ class FeedUIIntegrationTests: XCTestCase { loader.completeFeedLoading(with: [image0], at: 0) assertThat(sut, isRendering: [image0]) - sut.simulateUserInitiatedFeedReload() + sut.simulateUserInitiatedReload() loader.completeFeedLoadingWithError(at: 1) assertThat(sut, isRendering: [image0]) } @@ -126,7 +126,7 @@ class FeedUIIntegrationTests: XCTestCase { loader.completeFeedLoadingWithError(at: 0) XCTAssertEqual(sut.errorMessage, loadError) - sut.simulateUserInitiatedFeedReload() + sut.simulateUserInitiatedReload() XCTAssertEqual(sut.errorMessage, nil) } diff --git a/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift index f8bdc5d..f27f89e 100644 --- a/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift +++ b/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift @@ -38,7 +38,7 @@ extension ListViewController { refreshControl = spyRefreshControl } - func simulateUserInitiatedFeedReload() { + func simulateUserInitiatedReload() { refreshControl?.simulatePullToRefresh() } From e7a3ed99888a7f0f49b2b2d6c1704a6da44ac0aa Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 18 Mar 2025 22:49:27 -0300 Subject: [PATCH 06/15] Loading indicator is visible while loading comments --- .../CommentsUIIntegrationTests.swift | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift index 581fbe0..d4953b3 100644 --- a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift @@ -45,19 +45,19 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { XCTAssertEqual(loader.loadCommentsCallCount, 1, "Expected no loading request the second time view appears") } - override func test_loadingFeedIndicator_isVisibleWhileLoadingFeed() { + func test_loadingCommentsIndicator_isVisibleWhileLoadingComments() { let (sut, loader) = makeSUT() sut.simulateAppearance() XCTAssertTrue(sut.isShowingLoadingIndicator, "Expected loading indicator once view appears") - loader.completeFeedLoading(at: 0) + loader.completeCommentsLoading(at: 0) XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once loading completes successfully") sut.simulateUserInitiatedReload() XCTAssertTrue(sut.isShowingLoadingIndicator, "Expected loading indicator once user initiates a reload") - loader.completeFeedLoadingWithError(at: 1) + loader.completeCommentsLoadingWithError(at: 1) XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once user initiated loading completes with error") } @@ -71,11 +71,11 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { sut.simulateAppearance() assertThat(sut, isRendering: []) - loader.completeFeedLoading(with: [image0], at: 0) + loader.completeCommentsLoading(with: [image0], at: 0) assertThat(sut, isRendering: [image0]) sut.simulateUserInitiatedReload() - loader.completeFeedLoading(with: [image0, image1, image2, image3], at: 1) + loader.completeCommentsLoading(with: [image0, image1, image2, image3], at: 1) assertThat(sut, isRendering: [image0, image1, image2, image3]) } @@ -85,11 +85,11 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [image0, image1], at: 0) + loader.completeCommentsLoading(with: [image0, image1], at: 0) assertThat(sut, isRendering: [image0, image1]) sut.simulateUserInitiatedReload() - loader.completeFeedLoading(with: [], at: 1) + loader.completeCommentsLoading(with: [], at: 1) assertThat(sut, isRendering: []) } @@ -98,11 +98,11 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [image0], at: 0) + loader.completeCommentsLoading(with: [image0], at: 0) assertThat(sut, isRendering: [image0]) sut.simulateUserInitiatedReload() - loader.completeFeedLoadingWithError(at: 1) + loader.completeCommentsLoadingWithError(at: 1) assertThat(sut, isRendering: [image0]) } @@ -112,7 +112,7 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { let exp = expectation(description: "Wait for background queue") DispatchQueue.global().async { - loader.completeFeedLoading(at: 0) + loader.completeCommentsLoading(at: 0) exp.fulfill() } wait(for: [exp], timeout: 1.0) @@ -124,7 +124,7 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { sut.simulateAppearance() XCTAssertEqual(sut.errorMessage, nil) - loader.completeFeedLoadingWithError(at: 0) + loader.completeCommentsLoadingWithError(at: 0) XCTAssertEqual(sut.errorMessage, loadError) sut.simulateUserInitiatedReload() @@ -137,7 +137,7 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { sut.simulateAppearance() XCTAssertEqual(sut.errorMessage, nil) - loader.completeFeedLoadingWithError(at: 0) + loader.completeCommentsLoadingWithError(at: 0) XCTAssertEqual(sut.errorMessage, loadError) sut.simulateErrorViewTap() @@ -171,11 +171,11 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { return publisher.eraseToAnyPublisher() } - func completeFeedLoading(with feed: [FeedImage] = [], at index: Int = 0) { + func completeCommentsLoading(with feed: [FeedImage] = [], at index: Int = 0) { requests[index].send(feed) } - func completeFeedLoadingWithError(at index: Int = 0) { + func completeCommentsLoadingWithError(at index: Int = 0) { let error = NSError(domain: "an error", code: 0) requests[index].send(completion: .failure(error)) } From a55ca35436689cb7928ba0c8925c41dea5897bf6 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 18 Mar 2025 23:35:19 -0300 Subject: [PATCH 07/15] Render comments --- .../EssentialApp/CommentsUIComposer.swift | 45 ++++++++----- .../CommentsUIIntegrationTests.swift | 65 +++++++++++-------- .../ListViewController+TestHelpers.swift | 57 ++++++++++++---- .../ImageCommentsPresenter.swift | 2 +- 4 files changed, 111 insertions(+), 58 deletions(-) diff --git a/EssentialApp/EssentialApp/CommentsUIComposer.swift b/EssentialApp/EssentialApp/CommentsUIComposer.swift index a0e93eb..4ff7428 100644 --- a/EssentialApp/EssentialApp/CommentsUIComposer.swift +++ b/EssentialApp/EssentialApp/CommentsUIComposer.swift @@ -11,32 +11,43 @@ import EssentialFeediOS public final class CommentsUIComposer { private init() {} - private typealias FeedPresentationAdapter = LoadResourcePresentationAdapter<[FeedImage], FeedViewAdapter> + private typealias CommentsPresentationAdapter = LoadResourcePresentationAdapter<[ImageComment], CommentsViewAdapter> public static func commentsComposedWith( - commentsLoader: @escaping () -> AnyPublisher<[FeedImage], Error> + commentsLoader: @escaping () -> AnyPublisher<[ImageComment], Error> ) -> ListViewController { - let presentationAdapter = FeedPresentationAdapter(loader: commentsLoader) + let presentationAdapter = CommentsPresentationAdapter(loader: commentsLoader) - let feedController = makeFeedViewController(title: ImageCommentsPresenter.title) - feedController.onRefresh = presentationAdapter.loadResource + let commentsController = makeCommentsViewController(title: ImageCommentsPresenter.title) + commentsController.onRefresh = presentationAdapter.loadResource presentationAdapter.presenter = LoadResourcePresenter( - resourceView: FeedViewAdapter( - controller: feedController, - imageLoader: { _ in Empty().eraseToAnyPublisher() }), - loadingView: WeakRefVirtualProxy(feedController), - errorView: WeakRefVirtualProxy(feedController), - mapper: FeedPresenter.map) + resourceView: CommentsViewAdapter(controller: commentsController), + loadingView: WeakRefVirtualProxy(commentsController), + errorView: WeakRefVirtualProxy(commentsController), + mapper: { ImageCommentsPresenter.map($0) }) - return feedController + return commentsController } - private static func makeFeedViewController(title: String) -> ListViewController { + private static func makeCommentsViewController(title: String) -> ListViewController { let bundle = Bundle(for: ListViewController.self) - let storyboard = UIStoryboard(name: "Feed", bundle: bundle) - let feedController = storyboard.instantiateInitialViewController() as! ListViewController - feedController.title = title - return feedController + let storyboard = UIStoryboard(name: "ImageComments", bundle: bundle) + let controller = storyboard.instantiateInitialViewController() as! ListViewController + controller.title = title + return controller + }} + +final class CommentsViewAdapter: ResourceView { + private weak var controller: ListViewController? + + init(controller: ListViewController) { + self.controller = controller + } + + func display(_ viewModel: ImageCommentsViewModel) { + controller?.display(viewModel.comments.map { viewModel in + CellController(id: viewModel, ImageCommentCellController(model: viewModel)) + }) } } diff --git a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift index d4953b3..49afe1a 100644 --- a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift @@ -61,49 +61,46 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once user initiated loading completes with error") } - override func test_loadFeedCompletion_rendersSuccessfullyLoadedFeed() { - let image0 = makeImage(description: "a description", location: "a location") - let image1 = makeImage(description: nil, location: "another location") - let image2 = makeImage(description: "another description", location: nil) - let image3 = makeImage(description: nil, location: nil) + func test_loadCommentsCompletion_rendersSuccessfullyLoadedComments() { + let comment0 = makeComment(message: "a message", username: "a username") + let comment1 = makeComment(message: "another message", username: "another username") let (sut, loader) = makeSUT() sut.simulateAppearance() - assertThat(sut, isRendering: []) + assertThat(sut, isRendering: [ImageComment]()) - loader.completeCommentsLoading(with: [image0], at: 0) - assertThat(sut, isRendering: [image0]) + loader.completeCommentsLoading(with: [comment0], at: 0) + assertThat(sut, isRendering: [comment0]) sut.simulateUserInitiatedReload() - loader.completeCommentsLoading(with: [image0, image1, image2, image3], at: 1) - assertThat(sut, isRendering: [image0, image1, image2, image3]) + loader.completeCommentsLoading(with: [comment0, comment1], at: 1) + assertThat(sut, isRendering: [comment0, comment1]) } - override func test_loadFeedCompletion_rendersSuccessfullyLoadedEmptyFeedAfterNonEmptyFeed() { - let image0 = makeImage() - let image1 = makeImage() + func test_loadCommentsCompletion_rendersSuccessfullyLoadedEmptyCommentsAfterNonEmptyComments() { + let comment = makeComment() let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeCommentsLoading(with: [image0, image1], at: 0) - assertThat(sut, isRendering: [image0, image1]) + loader.completeCommentsLoading(with: [comment], at: 0) + assertThat(sut, isRendering: [comment]) sut.simulateUserInitiatedReload() loader.completeCommentsLoading(with: [], at: 1) - assertThat(sut, isRendering: []) + assertThat(sut, isRendering: [ImageComment]()) } - override func test_loadFeedCompletion_doesNotAlterCurrentRenderingStateOnError() { - let image0 = makeImage() + func test_loadCommentsCompletion_doesNotAlterCurrentRenderingStateOnError() { + let comment = makeComment() let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeCommentsLoading(with: [image0], at: 0) - assertThat(sut, isRendering: [image0]) + loader.completeCommentsLoading(with: [comment], at: 0) + assertThat(sut, isRendering: [comment]) sut.simulateUserInitiatedReload() loader.completeCommentsLoadingWithError(at: 1) - assertThat(sut, isRendering: [image0]) + assertThat(sut, isRendering: [comment]) } override func test_loadFeedCompletion_dispatchesFromBackgroundToMainThread() { @@ -154,25 +151,37 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { return (sut, loader) } - private func makeImage(description: String? = nil, location: String? = nil, url: URL = URL(string: "http://any-url.com")!) -> FeedImage { - return FeedImage(id: UUID(), description: description, location: location, url: url) + private func makeComment(message: String = "any message", username: String = "any username") -> ImageComment { + return ImageComment(id: UUID(), message: message, createdAt: Date(), username: username) + } + + private func assertThat(_ sut: ListViewController, isRendering comments: [ImageComment], file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(sut.numberOfRenderedComments(), comments.count, "comments count", file: file, line: line) + + let viewModel = ImageCommentsPresenter.map(comments) + + viewModel.comments.enumerated().forEach { index, comment in + XCTAssertEqual(sut.commentMessage(at: index), comment.message, "message at \(index)", file: file, line: line) + XCTAssertEqual(sut.commentDate(at: index), comment.date, "date at \(index)", file: file, line: line) + XCTAssertEqual(sut.commentUsername(at: index), comment.username, "username at \(index)", file: file, line: line) + } } private class LoaderSpy { - private var requests = [PassthroughSubject<[FeedImage], Error>]() + private var requests = [PassthroughSubject<[ImageComment], Error>]() var loadCommentsCallCount: Int { return requests.count } - func loadPublisher() -> AnyPublisher<[FeedImage], Error> { - let publisher = PassthroughSubject<[FeedImage], Error>() + func loadPublisher() -> AnyPublisher<[ImageComment], Error> { + let publisher = PassthroughSubject<[ImageComment], Error>() requests.append(publisher) return publisher.eraseToAnyPublisher() } - func completeCommentsLoading(with feed: [FeedImage] = [], at index: Int = 0) { - requests[index].send(feed) + func completeCommentsLoading(with comments: [ImageComment] = [], at index: Int = 0) { + requests[index].send(comments) } func completeCommentsLoadingWithError(at index: Int = 0) { diff --git a/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift index f27f89e..b5e57cc 100644 --- a/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift +++ b/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift @@ -42,6 +42,51 @@ extension ListViewController { refreshControl?.simulatePullToRefresh() } + var isShowingLoadingIndicator: Bool { + return refreshControl?.isRefreshing == true + } + + func simulateErrorViewTap() { + errorView.simulateTap() + } + + var errorMessage: String? { + return errorView.message + } +} + +extension ListViewController { + func numberOfRenderedComments() -> Int { + tableView.numberOfSections == 0 ? 0 : tableView.numberOfRows(inSection: commentsSection) + } + + func commentMessage(at row: Int) -> String? { + commentView(at: row)?.messageLabel.text + } + + func commentDate(at row: Int) -> String? { + commentView(at: row)?.dateLabel.text + } + + func commentUsername(at row: Int) -> String? { + commentView(at: row)?.usernameLabel.text + } + + private func commentView(at row: Int) -> ImageCommentCell? { + guard numberOfRenderedComments() > row else { + return nil + } + let ds = tableView.dataSource + let index = IndexPath(row: row, section: commentsSection) + return ds?.tableView(tableView, cellForRowAt: index) as? ImageCommentCell + } + + private var commentsSection: Int { + return 0 + } +} + +extension ListViewController { @discardableResult func simulateFeedImageViewVisible(at index: Int) -> FeedImageCell? { return feedImageView(at: index) as? FeedImageCell @@ -87,18 +132,6 @@ extension ListViewController { return simulateFeedImageViewVisible(at: index)?.renderedImage } - func simulateErrorViewTap() { - errorView.simulateTap() - } - - var errorMessage: String? { - return errorView.message - } - - var isShowingLoadingIndicator: Bool { - return refreshControl?.isRefreshing == true - } - func numberOfRenderedFeedImageViews() -> Int { tableView.numberOfSections == 0 ? 0 : tableView.numberOfRows(inSection: feedImagesSection) } diff --git a/EssentialFeed/EssentialFeed/Image Comments Presentation/ImageCommentsPresenter.swift b/EssentialFeed/EssentialFeed/Image Comments Presentation/ImageCommentsPresenter.swift index 9db6e69..a435024 100644 --- a/EssentialFeed/EssentialFeed/Image Comments Presentation/ImageCommentsPresenter.swift +++ b/EssentialFeed/EssentialFeed/Image Comments Presentation/ImageCommentsPresenter.swift @@ -9,7 +9,7 @@ public struct ImageCommentsViewModel { public let comments: [ImageCommentViewModel] } -public struct ImageCommentViewModel: Equatable { +public struct ImageCommentViewModel: Hashable { public let message: String public let date: String public let username: String From bbd2a9a5d7bc78e424ef5ba0023b999aef6638f4 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 18 Mar 2025 23:37:26 -0300 Subject: [PATCH 08/15] Dispatches from background to main queue --- EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift index 49afe1a..2ee4445 100644 --- a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift @@ -103,7 +103,7 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { assertThat(sut, isRendering: [comment]) } - override func test_loadFeedCompletion_dispatchesFromBackgroundToMainThread() { + func test_loadCommentsCompletion_dispatchesFromBackgroundToMainThread() { let (sut, loader) = makeSUT() sut.simulateAppearance() From a95ec7cbe490350afbcc35ee9abae2db8c1c35d5 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 18 Mar 2025 23:41:57 -0300 Subject: [PATCH 09/15] Move localization helpers to shared scope --- .../FeedUIIntegrationTests+Localization.swift | 26 ------------------- .../Helpers/SharedTestHelpers.swift | 16 ++++++++++++ 2 files changed, 16 insertions(+), 26 deletions(-) delete mode 100644 EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift deleted file mode 100644 index 1d9c6ea..0000000 --- a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2025 PortoCode. All Rights Reserved. -// - -import Foundation -import XCTest -import EssentialFeed - -extension FeedUIIntegrationTests { - private class DummyView: ResourceView { - func display(_ viewModel: Any) {} - } - - var loadError: String { - LoadResourcePresenter.loadError - } - - var feedTitle: String { - FeedPresenter.title - } - - var commentsTitle: String { - ImageCommentsPresenter.title - } -} diff --git a/EssentialApp/EssentialAppTests/Helpers/SharedTestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/SharedTestHelpers.swift index ff1bfe8..fec66bb 100644 --- a/EssentialApp/EssentialAppTests/Helpers/SharedTestHelpers.swift +++ b/EssentialApp/EssentialAppTests/Helpers/SharedTestHelpers.swift @@ -21,3 +21,19 @@ func anyData() -> Data { func uniqueFeed() -> [FeedImage] { return [FeedImage(id: UUID(), description: "any", location: "any", url: anyURL())] } + +private class DummyView: ResourceView { + func display(_ viewModel: Any) {} +} + +var loadError: String { + LoadResourcePresenter.loadError +} + +var feedTitle: String { + FeedPresenter.title +} + +var commentsTitle: String { + ImageCommentsPresenter.title +} From 1b3ec1eb8564311ce48d95e96c5d633b4971e17b Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 18 Mar 2025 23:42:16 -0300 Subject: [PATCH 10/15] Remove feed references from comments integration tests --- .../EssentialAppTests/CommentsUIIntegrationTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift index 2ee4445..0af8064 100644 --- a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift @@ -10,7 +10,7 @@ import EssentialApp import EssentialFeed import EssentialFeediOS -class CommentsUIIntegrationTests: FeedUIIntegrationTests { +class CommentsUIIntegrationTests: XCTestCase { func test_commentsView_hasTitle() { let (sut, _) = makeSUT() @@ -115,7 +115,7 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { wait(for: [exp], timeout: 1.0) } - override func test_loadFeedCompletion_rendersErrorMessageOnErrorUntilNextReload() { + func test_loadCommentsCompletion_rendersErrorMessageOnErrorUntilNextReload() { let (sut, loader) = makeSUT() sut.simulateAppearance() @@ -128,7 +128,7 @@ class CommentsUIIntegrationTests: FeedUIIntegrationTests { XCTAssertEqual(sut.errorMessage, nil) } - override func test_tapOnErrorView_hidesErrorMessage() { + func test_tapOnErrorView_hidesErrorMessage() { let (sut, loader) = makeSUT() sut.simulateAppearance() From 0efc05afe723561a50d95f7c1ac9deb1341023cb Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 18 Mar 2025 23:59:16 -0300 Subject: [PATCH 11/15] Proves request is canceled on comments view deinit --- .../CommentsUIIntegrationTests.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift index 0af8064..821b554 100644 --- a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift @@ -141,6 +141,29 @@ class CommentsUIIntegrationTests: XCTestCase { XCTAssertEqual(sut.errorMessage, nil) } + func test_deinit_cancelsRunningRequest() { + var cancelCallCount = 0 + + var sut: ListViewController? + + autoreleasepool { + sut = CommentsUIComposer.commentsComposedWith(commentsLoader: { + PassthroughSubject<[ImageComment], Error>() + .handleEvents(receiveCancel: { + cancelCallCount += 1 + }).eraseToAnyPublisher() + }) + + sut?.simulateAppearance() + } + + XCTAssertEqual(cancelCallCount, 0) + + sut = nil + + XCTAssertEqual(cancelCallCount, 1) + } + // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: ListViewController, loader: LoaderSpy) { From 7cbb52eedbf962a69544e9a34637d00f24efcf12 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 19 Mar 2025 21:16:52 -0300 Subject: [PATCH 12/15] Notifies selection handler on image selection --- .../EssentialApp/FeedUIComposer.swift | 6 ++-- .../EssentialApp/FeedViewAdapter.swift | 9 ++++-- .../FeedUIIntegrationTests.swift | 28 +++++++++++++++++-- .../ListViewController+TestHelpers.swift | 8 +++++- .../Controllers/FeedImageCellController.swift | 8 +++++- .../Controllers/ListViewController.swift | 5 ++++ .../Feed UI/FeedSnapshotTests.swift | 2 +- 7 files changed, 57 insertions(+), 9 deletions(-) diff --git a/EssentialApp/EssentialApp/FeedUIComposer.swift b/EssentialApp/EssentialApp/FeedUIComposer.swift index ebacf58..63b37eb 100644 --- a/EssentialApp/EssentialApp/FeedUIComposer.swift +++ b/EssentialApp/EssentialApp/FeedUIComposer.swift @@ -15,7 +15,8 @@ public final class FeedUIComposer { public static func feedComposedWith( feedLoader: @escaping () -> AnyPublisher<[FeedImage], Error>, - imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher + imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher, + selection: @escaping (FeedImage) -> Void = { _ in } ) -> ListViewController { let presentationAdapter = FeedPresentationAdapter(loader: feedLoader) @@ -25,7 +26,8 @@ public final class FeedUIComposer { presentationAdapter.presenter = LoadResourcePresenter( resourceView: FeedViewAdapter( controller: feedController, - imageLoader: imageLoader), + imageLoader: imageLoader, + selection: selection), loadingView: WeakRefVirtualProxy(feedController), errorView: WeakRefVirtualProxy(feedController), mapper: FeedPresenter.map) diff --git a/EssentialApp/EssentialApp/FeedViewAdapter.swift b/EssentialApp/EssentialApp/FeedViewAdapter.swift index ab615a6..7270b1d 100644 --- a/EssentialApp/EssentialApp/FeedViewAdapter.swift +++ b/EssentialApp/EssentialApp/FeedViewAdapter.swift @@ -10,12 +10,14 @@ import EssentialFeediOS final class FeedViewAdapter: ResourceView { private weak var controller: ListViewController? private let imageLoader: (URL) -> FeedImageDataLoader.Publisher + private let selection: (FeedImage) -> Void private typealias ImageDataPresentationAdapter = LoadResourcePresentationAdapter> - init(controller: ListViewController? = nil, imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher) { + init(controller: ListViewController? = nil, imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher, selection: @escaping (FeedImage) -> Void) { self.controller = controller self.imageLoader = imageLoader + self.selection = selection } func display(_ viewModel: FeedViewModel) { @@ -26,7 +28,10 @@ final class FeedViewAdapter: ResourceView { let view = FeedImageCellController( viewModel: FeedImagePresenter.map(model), - delegate: adapter) + delegate: adapter, + selection: { [selection] in + selection(model) + }) adapter.presenter = LoadResourcePresenter( resourceView: WeakRefVirtualProxy(view), diff --git a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift index 98e6a2c..ae76108 100644 --- a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift @@ -19,6 +19,22 @@ class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(sut.title, feedTitle) } + func test_imageSelection_notifiesHandler() { + let image0 = makeImage() + let image1 = makeImage() + var selectedImages = [FeedImage]() + let (sut, loader) = makeSUT(selection: { selectedImages.append($0) }) + + sut.simulateAppearance() + loader.completeFeedLoading(with: [image0, image1], at: 0) + + sut.simulateTapOnFeedImage(at: 0) + XCTAssertEqual(selectedImages, [image0]) + + sut.simulateTapOnFeedImage(at: 1) + XCTAssertEqual(selectedImages, [image0, image1]) + } + func test_loadFeedActions_requestFeedFromLoader() { let (sut, loader) = makeSUT() XCTAssertEqual(loader.loadFeedCallCount, 0, "Expected no loading requests before view appears") @@ -410,9 +426,17 @@ class FeedUIIntegrationTests: XCTestCase { // MARK: - Helpers - private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: ListViewController, loader: LoaderSpy) { + private func makeSUT( + selection: @escaping (FeedImage) -> Void = { _ in }, + file: StaticString = #file, + line: UInt = #line + ) -> (sut: ListViewController, loader: LoaderSpy) { let loader = LoaderSpy() - let sut = FeedUIComposer.feedComposedWith(feedLoader: loader.loadPublisher, imageLoader: loader.loadImageDataPublisher) + let sut = FeedUIComposer.feedComposedWith( + feedLoader: loader.loadPublisher, + imageLoader: loader.loadImageDataPublisher, + selection: selection + ) trackForMemoryLeaks(loader, file: file, line: line) trackForMemoryLeaks(sut, file: file, line: line) return (sut, loader) diff --git a/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift index b5e57cc..45507ce 100644 --- a/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift +++ b/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift @@ -57,7 +57,7 @@ extension ListViewController { extension ListViewController { func numberOfRenderedComments() -> Int { - tableView.numberOfSections == 0 ? 0 : tableView.numberOfRows(inSection: commentsSection) + tableView.numberOfSections == 0 ? 0 : tableView.numberOfRows(inSection: commentsSection) } func commentMessage(at row: Int) -> String? { @@ -114,6 +114,12 @@ extension ListViewController { return view } + func simulateTapOnFeedImage(at row: Int) { + let delegate = tableView.delegate + let index = IndexPath(row: row, section: feedImagesSection) + delegate?.tableView?(tableView, didSelectRowAt: index) + } + func simulateFeedImageViewNearVisible(at row: Int) { let ds = tableView.prefetchDataSource let index = IndexPath(row: row, section: feedImagesSection) diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift index 0be718a..cc8934c 100644 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift @@ -14,11 +14,13 @@ public protocol FeedImageCellControllerDelegate { public final class FeedImageCellController: NSObject { private let viewModel: FeedImageViewModel private let delegate: FeedImageCellControllerDelegate + private let selection: () -> Void private var cell: FeedImageCell? - public init(viewModel: FeedImageViewModel, delegate: FeedImageCellControllerDelegate) { + public init(viewModel: FeedImageViewModel, delegate: FeedImageCellControllerDelegate, selection: @escaping () -> Void) { self.viewModel = viewModel self.delegate = delegate + self.selection = selection } } @@ -43,6 +45,10 @@ extension FeedImageCellController: UITableViewDataSource, UITableViewDelegate, U return cell! } + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + selection() + } + public func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { self.cell = cell as? FeedImageCell delegate.didRequestImage() diff --git a/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift b/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift index b654e8f..f391c67 100644 --- a/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift +++ b/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift @@ -85,6 +85,11 @@ public final class ListViewController: UITableViewController, UITableViewDataSou onViewIsAppearing?(self) } + public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let dl = cellController(at: indexPath)?.delegate + dl?.tableView?(tableView, didSelectRowAt: indexPath) + } + public override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { let dl = cellController(at: indexPath)?.delegate dl?.tableView?(tableView, willDisplay: cell, forRowAt: indexPath) diff --git a/EssentialFeed/EssentialFeediOSTests/Feed UI/FeedSnapshotTests.swift b/EssentialFeed/EssentialFeediOSTests/Feed UI/FeedSnapshotTests.swift index bdd2221..b4a259b 100644 --- a/EssentialFeed/EssentialFeediOSTests/Feed UI/FeedSnapshotTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/Feed UI/FeedSnapshotTests.swift @@ -75,7 +75,7 @@ class FeedSnapshotTests: XCTestCase { private extension ListViewController { func display(_ stubs: [ImageStub]) { let cells: [CellController] = stubs.map { stub in - let cellController = FeedImageCellController(viewModel: stub.viewModel, delegate: stub) + let cellController = FeedImageCellController(viewModel: stub.viewModel, delegate: stub, selection: {}) stub.controller = cellController return CellController(id: UUID(), cellController) } From fa7654aa314c2ba7ac5d3a0dc84bf0317996735c Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 19 Mar 2025 21:57:19 -0300 Subject: [PATCH 13/15] Show comments on image selection --- EssentialApp/EssentialApp/SceneDelegate.swift | 31 ++++++++++++--- .../FeedAcceptanceTests.swift | 39 +++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index aa90cda..c824f21 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -26,7 +26,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { LocalFeedLoader(store: store, currentDate: Date.init) }() - private let remoteURL = URL(string: "https://ile-api.essentialdeveloper.com/essential-feed/v1/feed")! + private lazy var baseURL = URL(string: "https://ile-api.essentialdeveloper.com/essential-feed")! + + private lazy var navigationController = UINavigationController( + rootViewController: FeedUIComposer.feedComposedWith( + feedLoader: makeRemoteFeedLoaderWithLocalFallback, + imageLoader: makeLocalImageLoaderWithRemoteFallback, + selection: showComments)) convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore) { self.init() @@ -42,11 +48,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } func configureWindow() { - window?.rootViewController = UINavigationController( - rootViewController: FeedUIComposer.feedComposedWith( - feedLoader: makeRemoteFeedLoaderWithLocalFallback, - imageLoader: makeLocalImageLoaderWithRemoteFallback)) - + window?.rootViewController = navigationController window?.makeKeyAndVisible() } @@ -54,7 +56,24 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { localFeedLoader.validateCache { _ in } } + private func showComments(for image: FeedImage) { + let url = baseURL.appendingPathComponent("/v1/image/\(image.id)/comments") + let comments = CommentsUIComposer.commentsComposedWith(commentsLoader: makeRemoteCommentsLoader(url: url)) + navigationController.pushViewController(comments, animated: true) + } + + private func makeRemoteCommentsLoader(url: URL) -> () -> AnyPublisher<[ImageComment], Error> { + return { [httpClient] in + return httpClient + .getPublisher(url: url) + .tryMap(ImageCommentsMapper.map) + .eraseToAnyPublisher() + } + } + private func makeRemoteFeedLoaderWithLocalFallback() -> AnyPublisher<[FeedImage], Error> { + let remoteURL = baseURL.appendingPathComponent("/v1/feed") + return httpClient .getPublisher(url: remoteURL) .tryMap(FeedItemsMapper.map) diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index 63b237d..85cc6d0 100644 --- a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -53,6 +53,13 @@ class FeedAcceptanceTests: XCTestCase { XCTAssertNotNil(store.feedCache, "Expected to keep non-expired cache") } + func test_onFeedImageSelection_displaysComments() { + let comments = showCommentsForFirstImage() + + XCTAssertEqual(comments.numberOfRenderedComments(), 1) + XCTAssertEqual(comments.commentMessage(at: 0), makeCommentMessage()) + } + // MARK: - Helpers private func launch( @@ -74,6 +81,18 @@ class FeedAcceptanceTests: XCTestCase { sut.sceneWillResignActive(UIApplication.shared.connectedScenes.first!) } + private func showCommentsForFirstImage() -> ListViewController { + let feed = launch(httpClient: .online(response), store: .empty) + + feed.simulateTapOnFeedImage(at: 0) + RunLoop.current.run(until: Date()) + + let nav = feed.navigationController + let vc = nav?.topViewController as! ListViewController + vc.simulateAppearance() + return vc + } + private func response(for url: URL) -> (Data, HTTPURLResponse) { let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! return (makeData(for: url), response) @@ -86,6 +105,9 @@ class FeedAcceptanceTests: XCTestCase { case "/essential-feed/v1/feed": return makeFeedData() + + case "/essential-feed/v1/image/2AB2AE66-A4B7-4A16-B374-51BBAC8DB086/comments": + return makeCommentsData() default: return Data() @@ -103,4 +125,21 @@ class FeedAcceptanceTests: XCTestCase { ]]) } + private func makeCommentsData() -> Data { + return try! JSONSerialization.data(withJSONObject: ["items": [ + [ + "id": UUID().uuidString, + "message": makeCommentMessage(), + "created_at": "2020-05-20T11:24:59+0000", + "author": [ + "username": "a username" + ] + ], + ]]) + } + + private func makeCommentMessage() -> String { + "a message" + } + } From 1f3f92db0d030dd65543b3c6049d40a99f3e278c Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 19 Mar 2025 23:33:01 -0300 Subject: [PATCH 14/15] Extract endpoint URL creation to the API modules --- EssentialApp/EssentialApp/SceneDelegate.swift | 6 +++--- .../EssentialFeed.xcodeproj/project.pbxproj | 16 ++++++++++++++ .../EssentialFeed/Feed API/FeedEndpoint.swift | 17 +++++++++++++++ .../ImageCommentsEndpoint.swift | 17 +++++++++++++++ .../Feed API/FeedEndpointTests.swift | 20 ++++++++++++++++++ .../ImageCommentsEndpointTests.swift | 21 +++++++++++++++++++ 6 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 EssentialFeed/EssentialFeed/Feed API/FeedEndpoint.swift create mode 100644 EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsEndpoint.swift create mode 100644 EssentialFeed/EssentialFeedTests/Feed API/FeedEndpointTests.swift create mode 100644 EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsEndpointTests.swift diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index c824f21..1c3d667 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -57,7 +57,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } private func showComments(for image: FeedImage) { - let url = baseURL.appendingPathComponent("/v1/image/\(image.id)/comments") + let url = ImageCommentsEndpoint.get(image.id).url(baseURL: baseURL) let comments = CommentsUIComposer.commentsComposedWith(commentsLoader: makeRemoteCommentsLoader(url: url)) navigationController.pushViewController(comments, animated: true) } @@ -72,10 +72,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } private func makeRemoteFeedLoaderWithLocalFallback() -> AnyPublisher<[FeedImage], Error> { - let remoteURL = baseURL.appendingPathComponent("/v1/feed") + let url = FeedEndpoint.get.url(baseURL: baseURL) return httpClient - .getPublisher(url: remoteURL) + .getPublisher(url: url) .tryMap(FeedItemsMapper.map) .caching(to: localFeedLoader) .fallback(to: localFeedLoader.loadPublisher) diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 524b5c8..806685b 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -36,6 +36,10 @@ 5B19263C2D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_light.png in Resources */ = {isa = PBXBuildFile; fileRef = 5B1926372D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_light.png */; }; 5B19263D2D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_dark.png in Resources */ = {isa = PBXBuildFile; fileRef = 5B1926362D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_dark.png */; }; 5B19263F2D891F7F006C9C65 /* UIView+Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B19263E2D891F7F006C9C65 /* UIView+Container.swift */; }; + 5B1926552D8BAFB1006C9C65 /* ImageCommentsEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1926542D8BAFB1006C9C65 /* ImageCommentsEndpoint.swift */; }; + 5B1926572D8BB459006C9C65 /* FeedEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1926562D8BB459006C9C65 /* FeedEndpoint.swift */; }; + 5B1926592D8BB52A006C9C65 /* FeedEndpointTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1926582D8BB52A006C9C65 /* FeedEndpointTests.swift */; }; + 5B19265B2D8BB575006C9C65 /* ImageCommentsEndpointTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B19265A2D8BB575006C9C65 /* ImageCommentsEndpointTests.swift */; }; 5B1C4F9B2C0556ED003F0429 /* URLSessionHTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1C4F9A2C0556ED003F0429 /* URLSessionHTTPClient.swift */; }; 5B1C4FC92C057236003F0429 /* EssentialFeedAPIEndToEndTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1C4FC82C057236003F0429 /* EssentialFeedAPIEndToEndTests.swift */; }; 5B1C4FCA2C057236003F0429 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B107DF82BF5BB2100927709 /* EssentialFeed.framework */; }; @@ -213,6 +217,10 @@ 5B1926372D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_light.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = LIST_WITH_ERROR_MESSAGE_light.png; sourceTree = ""; }; 5B1926382D890348006C9C65 /* LIST_WITH_ERROR_MESSAGE_light_extraExtraExtraLarge.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = LIST_WITH_ERROR_MESSAGE_light_extraExtraExtraLarge.png; sourceTree = ""; }; 5B19263E2D891F7F006C9C65 /* UIView+Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Container.swift"; sourceTree = ""; }; + 5B1926542D8BAFB1006C9C65 /* ImageCommentsEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCommentsEndpoint.swift; sourceTree = ""; }; + 5B1926562D8BB459006C9C65 /* FeedEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedEndpoint.swift; sourceTree = ""; }; + 5B1926582D8BB52A006C9C65 /* FeedEndpointTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedEndpointTests.swift; sourceTree = ""; }; + 5B19265A2D8BB575006C9C65 /* ImageCommentsEndpointTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCommentsEndpointTests.swift; sourceTree = ""; }; 5B1C4F9A2C0556ED003F0429 /* URLSessionHTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionHTTPClient.swift; sourceTree = ""; }; 5B1C4F9C2C056BB6003F0429 /* EssentialFeed.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = EssentialFeed.xctestplan; sourceTree = ""; }; 5B1C4FC62C057236003F0429 /* EssentialFeedAPIEndToEndTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedAPIEndToEndTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -384,6 +392,7 @@ children = ( 5B7AB8D62BF8BCE60034C68B /* FeedItemsMapperTests.swift */, 5B8829122D6BAA6C006E0BD7 /* FeedImageDataMapperTests.swift */, + 5B1926582D8BB52A006C9C65 /* FeedEndpointTests.swift */, ); path = "Feed API"; sourceTree = ""; @@ -565,6 +574,7 @@ isa = PBXGroup; children = ( 5B7349152D81A28D007F7D5D /* ImageCommentsMapper.swift */, + 5B1926542D8BAFB1006C9C65 /* ImageCommentsEndpoint.swift */, ); path = "Image Comments API"; sourceTree = ""; @@ -573,6 +583,7 @@ isa = PBXGroup; children = ( 5B7349112D819FC7007F7D5D /* ImageCommentsMapperTests.swift */, + 5B19265A2D8BB575006C9C65 /* ImageCommentsEndpointTests.swift */, ); path = "Image Comments API"; sourceTree = ""; @@ -767,6 +778,7 @@ 5B88291A2D6BD697006E0BD7 /* Helpers */, 5B0E220C2BFE3135009FC3EB /* FeedItemsMapper.swift */, 5B7349272D829960007F7D5D /* FeedImageDataMapper.swift */, + 5B1926562D8BB459006C9C65 /* FeedEndpoint.swift */, ); path = "Feed API"; sourceTree = ""; @@ -1161,7 +1173,9 @@ 5B7349162D81A28D007F7D5D /* ImageCommentsMapper.swift in Sources */, 5B73492E2D843BBE007F7D5D /* LoadResourcePresenter.swift in Sources */, 5B0E220B2BFE2FEA009FC3EB /* HTTPClient.swift in Sources */, + 5B1926572D8BB459006C9C65 /* FeedEndpoint.swift in Sources */, 5B107E132BF5BB4200927709 /* FeedImage.swift in Sources */, + 5B1926552D8BAFB1006C9C65 /* ImageCommentsEndpoint.swift in Sources */, 5BBDA01A2D6FF5F100D68DF0 /* FeedImageDataCache.swift in Sources */, 5B8829242D6BEB71006E0BD7 /* FeedImageDataStore.swift in Sources */, 5B8829032D6A7401006E0BD7 /* FeedPresenter.swift in Sources */, @@ -1200,12 +1214,14 @@ 5B8829202D6BE22C006E0BD7 /* LoadFeedImageDataFromCacheUseCaseTests.swift in Sources */, 5B7349122D819FC7007F7D5D /* ImageCommentsMapperTests.swift in Sources */, 5B034B3F2CA0BAA500FB65F8 /* FeedStoreSpy.swift in Sources */, + 5B19265B2D8BB575006C9C65 /* ImageCommentsEndpointTests.swift in Sources */, 5B034B452CA3A1A100FB65F8 /* SharedTestHelpers.swift in Sources */, 5B8BD18F2C3798D400CCA870 /* CacheFeedUseCaseTests.swift in Sources */, 5B7349352D844D6D007F7D5D /* SharedLocalizationTests.swift in Sources */, 5B8829262D6BF168006E0BD7 /* FeedImageDataStoreSpy.swift in Sources */, 5B73494E2D85356B007F7D5D /* ImageCommentsLocalizationTests.swift in Sources */, 5B88292B2D6BF4F0006E0BD7 /* CoreDataFeedImageDataStoreTests.swift in Sources */, + 5B1926592D8BB52A006C9C65 /* FeedEndpointTests.swift in Sources */, 5BF9F3032CD9A1C600C8DB96 /* XCTestCase+FailableDeleteFeedStoreSpecs.swift in Sources */, 5BF9F2FF2CD99FF300C8DB96 /* XCTestCase+FailableRetrieveFeedStoreSpecs.swift in Sources */, 5B034B3B2CA0B09F00FB65F8 /* LoadFeedFromCacheUseCaseTests.swift in Sources */, diff --git a/EssentialFeed/EssentialFeed/Feed API/FeedEndpoint.swift b/EssentialFeed/EssentialFeed/Feed API/FeedEndpoint.swift new file mode 100644 index 0000000..53bdd9f --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed API/FeedEndpoint.swift @@ -0,0 +1,17 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation + +public enum FeedEndpoint { + case get + + public func url(baseURL: URL) -> URL { + switch self { + case .get: + return baseURL.appendingPathComponent("/v1/feed") + } + } +} diff --git a/EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsEndpoint.swift b/EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsEndpoint.swift new file mode 100644 index 0000000..98c54e0 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Image Comments API/ImageCommentsEndpoint.swift @@ -0,0 +1,17 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation + +public enum ImageCommentsEndpoint { + case get(UUID) + + public func url(baseURL: URL) -> URL { + switch self { + case let .get(id): + return baseURL.appendingPathComponent("/v1/image/\(id)/comments") + } + } +} diff --git a/EssentialFeed/EssentialFeedTests/Feed API/FeedEndpointTests.swift b/EssentialFeed/EssentialFeedTests/Feed API/FeedEndpointTests.swift new file mode 100644 index 0000000..6e7c714 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed API/FeedEndpointTests.swift @@ -0,0 +1,20 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeed + +class FeedEndpointTests: XCTestCase { + + func test_feed_endpointURL() { + let baseURL = URL(string: "http://base-url.com")! + + let received = FeedEndpoint.get.url(baseURL: baseURL) + let expected = URL(string: "http://base-url.com/v1/feed")! + + XCTAssertEqual(received, expected) + } + +} diff --git a/EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsEndpointTests.swift b/EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsEndpointTests.swift new file mode 100644 index 0000000..fb38e28 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Image Comments API/ImageCommentsEndpointTests.swift @@ -0,0 +1,21 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import EssentialFeed + +class ImageCommentsEndpointTests: XCTestCase { + + func test_imageComments_endpointURL() { + let imageID = UUID(uuidString: "2239CBA2-CB35-4392-ADC0-24A37D38E010")! + let baseURL = URL(string: "http://base-url.com")! + + let received = ImageCommentsEndpoint.get(imageID).url(baseURL: baseURL) + let expected = URL(string: "http://base-url.com/v1/image/2239CBA2-CB35-4392-ADC0-24A37D38E010/comments")! + + XCTAssertEqual(received, expected) + } + +} From 6905d08c9f83e7bccec4a7ceee1ddcc2490bc333 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Wed, 19 Mar 2025 23:37:47 -0300 Subject: [PATCH 15/15] Extract helper methods to remove duplication --- .../ListViewController+TestHelpers.swift | 39 +++++++++---------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift index 45507ce..fa96c48 100644 --- a/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift +++ b/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift @@ -53,11 +53,24 @@ extension ListViewController { var errorMessage: String? { return errorView.message } + + func numberOfRows(in section: Int) -> Int { + tableView.numberOfSections > section ? tableView.numberOfRows(inSection: section) : 0 + } + + func cell(row: Int, section: Int) -> UITableViewCell? { + guard numberOfRows(in: section) > row else { + return nil + } + let ds = tableView.dataSource + let index = IndexPath(row: row, section: section) + return ds?.tableView(tableView, cellForRowAt: index) + } } extension ListViewController { func numberOfRenderedComments() -> Int { - tableView.numberOfSections == 0 ? 0 : tableView.numberOfRows(inSection: commentsSection) + numberOfRows(in: commentsSection) } func commentMessage(at row: Int) -> String? { @@ -73,17 +86,10 @@ extension ListViewController { } private func commentView(at row: Int) -> ImageCommentCell? { - guard numberOfRenderedComments() > row else { - return nil - } - let ds = tableView.dataSource - let index = IndexPath(row: row, section: commentsSection) - return ds?.tableView(tableView, cellForRowAt: index) as? ImageCommentCell + cell(row: row, section: commentsSection) as? ImageCommentCell } - private var commentsSection: Int { - return 0 - } + private var commentsSection: Int { 0 } } extension ListViewController { @@ -139,19 +145,12 @@ extension ListViewController { } func numberOfRenderedFeedImageViews() -> Int { - tableView.numberOfSections == 0 ? 0 : tableView.numberOfRows(inSection: feedImagesSection) + numberOfRows(in: feedImagesSection) } func feedImageView(at row: Int) -> UITableViewCell? { - guard numberOfRenderedFeedImageViews() > row else { - return nil - } - let ds = tableView.dataSource - let index = IndexPath(row: row, section: feedImagesSection) - return ds?.tableView(tableView, cellForRowAt: index) + cell(row: row, section: feedImagesSection) } - private var feedImagesSection: Int { - return 0 - } + private var feedImagesSection: Int { 0 } }