diff --git a/EssentialApp/EssentialApp/CommentsUIComposer.swift b/EssentialApp/EssentialApp/CommentsUIComposer.swift new file mode 100644 index 0000000..4ff7428 --- /dev/null +++ b/EssentialApp/EssentialApp/CommentsUIComposer.swift @@ -0,0 +1,53 @@ +// +// 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 CommentsPresentationAdapter = LoadResourcePresentationAdapter<[ImageComment], CommentsViewAdapter> + + public static func commentsComposedWith( + commentsLoader: @escaping () -> AnyPublisher<[ImageComment], Error> + ) -> ListViewController { + let presentationAdapter = CommentsPresentationAdapter(loader: commentsLoader) + + let commentsController = makeCommentsViewController(title: ImageCommentsPresenter.title) + commentsController.onRefresh = presentationAdapter.loadResource + + presentationAdapter.presenter = LoadResourcePresenter( + resourceView: CommentsViewAdapter(controller: commentsController), + loadingView: WeakRefVirtualProxy(commentsController), + errorView: WeakRefVirtualProxy(commentsController), + mapper: { ImageCommentsPresenter.map($0) }) + + return commentsController + } + + private static func makeCommentsViewController(title: String) -> ListViewController { + let bundle = Bundle(for: ListViewController.self) + 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/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/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index aa90cda..1c3d667 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,9 +56,26 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { localFeedLoader.validateCache { _ in } } + private func showComments(for image: FeedImage) { + let url = ImageCommentsEndpoint.get(image.id).url(baseURL: baseURL) + 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 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/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift new file mode 100644 index 0000000..821b554 --- /dev/null +++ b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift @@ -0,0 +1,216 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import XCTest +import Combine +import UIKit +import EssentialApp +import EssentialFeed +import EssentialFeediOS + +class CommentsUIIntegrationTests: XCTestCase { + + func test_commentsView_hasTitle() { + let (sut, _) = makeSUT() + + sut.simulateAppearance() + + XCTAssertEqual(sut.title, commentsTitle) + } + + func test_loadCommentsActions_requestCommentsFromLoader() { + let (sut, loader) = makeSUT() + XCTAssertEqual(loader.loadCommentsCallCount, 0, "Expected no loading requests before view appears") + + sut.simulateAppearance() + XCTAssertEqual(loader.loadCommentsCallCount, 1, "Expected a loading request once view appears") + + sut.simulateUserInitiatedReload() + XCTAssertEqual(loader.loadCommentsCallCount, 2, "Expected another loading request once user initiates a reload") + + sut.simulateUserInitiatedReload() + XCTAssertEqual(loader.loadCommentsCallCount, 3, "Expected yet another loading request once user initiates another reload") + } + + func test_loadCommentsActions_runsAutomaticallyOnlyOnFirstAppearance() { + let (sut, loader) = makeSUT() + XCTAssertEqual(loader.loadCommentsCallCount, 0, "Expected no loading requests before view appears") + + sut.simulateAppearance() + XCTAssertEqual(loader.loadCommentsCallCount, 1, "Expected a loading request once view appears") + + sut.simulateAppearance() + XCTAssertEqual(loader.loadCommentsCallCount, 1, "Expected no loading request the second time view appears") + } + + func test_loadingCommentsIndicator_isVisibleWhileLoadingComments() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + XCTAssertTrue(sut.isShowingLoadingIndicator, "Expected loading indicator once view appears") + + 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.completeCommentsLoadingWithError(at: 1) + XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once user initiated loading completes with error") + } + + 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: [ImageComment]()) + + loader.completeCommentsLoading(with: [comment0], at: 0) + assertThat(sut, isRendering: [comment0]) + + sut.simulateUserInitiatedReload() + loader.completeCommentsLoading(with: [comment0, comment1], at: 1) + assertThat(sut, isRendering: [comment0, comment1]) + } + + func test_loadCommentsCompletion_rendersSuccessfullyLoadedEmptyCommentsAfterNonEmptyComments() { + let comment = makeComment() + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeCommentsLoading(with: [comment], at: 0) + assertThat(sut, isRendering: [comment]) + + sut.simulateUserInitiatedReload() + loader.completeCommentsLoading(with: [], at: 1) + assertThat(sut, isRendering: [ImageComment]()) + } + + func test_loadCommentsCompletion_doesNotAlterCurrentRenderingStateOnError() { + let comment = makeComment() + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeCommentsLoading(with: [comment], at: 0) + assertThat(sut, isRendering: [comment]) + + sut.simulateUserInitiatedReload() + loader.completeCommentsLoadingWithError(at: 1) + assertThat(sut, isRendering: [comment]) + } + + func test_loadCommentsCompletion_dispatchesFromBackgroundToMainThread() { + let (sut, loader) = makeSUT() + sut.simulateAppearance() + + let exp = expectation(description: "Wait for background queue") + DispatchQueue.global().async { + loader.completeCommentsLoading(at: 0) + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + } + + func test_loadCommentsCompletion_rendersErrorMessageOnErrorUntilNextReload() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + XCTAssertEqual(sut.errorMessage, nil) + + loader.completeCommentsLoadingWithError(at: 0) + XCTAssertEqual(sut.errorMessage, loadError) + + sut.simulateUserInitiatedReload() + XCTAssertEqual(sut.errorMessage, nil) + } + + func test_tapOnErrorView_hidesErrorMessage() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + XCTAssertEqual(sut.errorMessage, nil) + + loader.completeCommentsLoadingWithError(at: 0) + XCTAssertEqual(sut.errorMessage, loadError) + + sut.simulateErrorViewTap() + 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) { + 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 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<[ImageComment], Error>]() + + var loadCommentsCallCount: Int { + return requests.count + } + + func loadPublisher() -> AnyPublisher<[ImageComment], Error> { + let publisher = PassthroughSubject<[ImageComment], Error>() + requests.append(publisher) + return publisher.eraseToAnyPublisher() + } + + func completeCommentsLoading(with comments: [ImageComment] = [], at index: Int = 0) { + requests[index].send(comments) + } + + func completeCommentsLoadingWithError(at index: Int = 0) { + let error = NSError(domain: "an error", code: 0) + requests[index].send(completion: .failure(error)) + } + } + +} diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index 1b16026..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,18 +81,36 @@ 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) } 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() + + case "/essential-feed/v1/image/2AB2AE66-A4B7-4A16-B374-51BBAC8DB086/comments": + return makeCommentsData() + + default: + return Data() } } @@ -95,9 +120,26 @@ 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"] + ]]) + } + + 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" + } + } diff --git a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift index 8b9c31c..ae76108 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() @@ -19,6 +19,22 @@ final 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") @@ -26,10 +42,10 @@ final 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 +69,7 @@ final 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 +89,7 @@ final 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 +103,7 @@ final 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,11 +116,23 @@ final 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]) } + 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() @@ -114,7 +142,7 @@ final class FeedUIIntegrationTests: XCTestCase { loader.completeFeedLoadingWithError(at: 0) XCTAssertEqual(sut.errorMessage, loadError) - sut.simulateUserInitiatedFeedReload() + sut.simulateUserInitiatedReload() XCTAssertEqual(sut.errorMessage, nil) } @@ -131,6 +159,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 +409,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() @@ -408,9 +426,17 @@ final 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/FeedUIIntegrationTests+Localization.swift b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift deleted file mode 100644 index d09595d..0000000 --- a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+Localization.swift +++ /dev/null @@ -1,22 +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 - } -} diff --git a/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift index f8bdc5d..fa96c48 100644 --- a/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift +++ b/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift @@ -38,10 +38,61 @@ extension ListViewController { refreshControl = spyRefreshControl } - func simulateUserInitiatedFeedReload() { + func simulateUserInitiatedReload() { refreshControl?.simulatePullToRefresh() } + var isShowingLoadingIndicator: Bool { + return refreshControl?.isRefreshing == true + } + + func simulateErrorViewTap() { + errorView.simulateTap() + } + + 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 { + numberOfRows(in: 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? { + cell(row: row, section: commentsSection) as? ImageCommentCell + } + + private var commentsSection: Int { 0 } +} + +extension ListViewController { @discardableResult func simulateFeedImageViewVisible(at index: Int) -> FeedImageCell? { return feedImageView(at: index) as? FeedImageCell @@ -69,6 +120,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) @@ -87,32 +144,13 @@ 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) + 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 } } 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 +} 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/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 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) + } + +} 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) }