Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions EssentialApp/EssentialApp/CommentsUIComposer.swift
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
6 changes: 4 additions & 2 deletions EssentialApp/EssentialApp/FeedUIComposer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions EssentialApp/EssentialApp/FeedViewAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Data, WeakRefVirtualProxy<FeedImageCellController>>

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) {
Expand All @@ -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),
Expand Down
33 changes: 26 additions & 7 deletions EssentialApp/EssentialApp/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -42,21 +48,34 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}

func configureWindow() {
window?.rootViewController = UINavigationController(
rootViewController: FeedUIComposer.feedComposedWith(
feedLoader: makeRemoteFeedLoaderWithLocalFallback,
imageLoader: makeLocalImageLoaderWithRemoteFallback))

window?.rootViewController = navigationController
window?.makeKeyAndVisible()
}

func sceneWillResignActive(_ scene: UIScene) {
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)
Expand Down
216 changes: 216 additions & 0 deletions EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}

}
Loading