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
5 changes: 2 additions & 3 deletions EssentialApp/EssentialApp/CommentsUIComposer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,17 @@
//

import UIKit
import Combine
import EssentialFeed
import EssentialFeediOS

@MainActor
public final class CommentsUIComposer {
private init() {}

private typealias CommentsPresentationAdapter = LoadResourcePresentationAdapter<[ImageComment], CommentsViewAdapter>
private typealias CommentsPresentationAdapter = AsyncLoadResourcePresentationAdapter<[ImageComment], CommentsViewAdapter>

public static func commentsComposedWith(
commentsLoader: @escaping () -> AnyPublisher<[ImageComment], Error>
commentsLoader: @escaping () async throws -> [ImageComment]
) -> ListViewController {
let presentationAdapter = CommentsPresentationAdapter(loader: commentsLoader)

Expand Down
11 changes: 4 additions & 7 deletions EssentialApp/EssentialApp/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import os
import UIKit
import CoreData
import Combine
import EssentialFeed

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
Expand Down Expand Up @@ -83,16 +82,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {

private func showComments(for image: FeedImage) {
let url = ImageCommentsEndpoint.get(image.id).url(baseURL: baseURL)
let comments = CommentsUIComposer.commentsComposedWith(commentsLoader: makeRemoteCommentsLoader(url: url))
let comments = CommentsUIComposer.commentsComposedWith(commentsLoader: loadComments(url: url))
navigationController.pushViewController(comments, animated: true)
}

private func makeRemoteCommentsLoader(url: URL) -> () -> AnyPublisher<[ImageComment], Error> {
private func loadComments(url: URL) -> () async throws -> [ImageComment] {
return { [httpClient] in
return httpClient
.getPublisher(url: url)
.tryMap(ImageCommentsMapper.map)
.eraseToAnyPublisher()
let (data, response) = try await httpClient.get(from: url)
return try ImageCommentsMapper.map(data, from: response)
}
}

Expand Down
117 changes: 58 additions & 59 deletions EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
//

import XCTest
import Combine
import UIKit
import EssentialApp
import EssentialFeed
Expand All @@ -12,15 +11,15 @@ import EssentialFeediOS
@MainActor
class CommentsUIIntegrationTests: XCTestCase {

func test_commentsView_hasTitle() {
func test_commentsView_hasTitle() async {
let (sut, _) = makeSUT()

sut.simulateAppearance()

XCTAssertEqual(sut.title, commentsTitle)
}

func test_loadCommentsActions_requestCommentsFromLoader() {
func test_loadCommentsActions_requestCommentsFromLoader() async {
let (sut, loader) = makeSUT()
XCTAssertEqual(loader.loadCommentsCallCount, 0, "Expected no loading requests before view appears")

Expand All @@ -30,16 +29,16 @@ class CommentsUIIntegrationTests: XCTestCase {
sut.simulateUserInitiatedReload()
XCTAssertEqual(loader.loadCommentsCallCount, 1, "Expected no request until previous completes")

loader.completeCommentsLoading(at: 0)
await loader.completeCommentsLoading(at: 0)
sut.simulateUserInitiatedReload()
XCTAssertEqual(loader.loadCommentsCallCount, 2, "Expected another loading request once user initiates a reload")

loader.completeCommentsLoading(at: 1)
await loader.completeCommentsLoading(at: 1)
sut.simulateUserInitiatedReload()
XCTAssertEqual(loader.loadCommentsCallCount, 3, "Expected yet another loading request once user initiates another reload")
}

func test_loadCommentsActions_runsAutomaticallyOnlyOnFirstAppearance() {
func test_loadCommentsActions_runsAutomaticallyOnlyOnFirstAppearance() async {
let (sut, loader) = makeSUT()
XCTAssertEqual(loader.loadCommentsCallCount, 0, "Expected no loading requests before view appears")

Expand All @@ -50,120 +49,121 @@ class CommentsUIIntegrationTests: XCTestCase {
XCTAssertEqual(loader.loadCommentsCallCount, 1, "Expected no loading request the second time view appears")
}

func test_loadingCommentsIndicator_isVisibleWhileLoadingComments() {
func test_loadingCommentsIndicator_isVisibleWhileLoadingComments() async {
let (sut, loader) = makeSUT()

sut.simulateAppearance()
XCTAssertTrue(sut.isShowingLoadingIndicator, "Expected loading indicator once view appears")

loader.completeCommentsLoading(at: 0)
await 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)
await loader.completeCommentsLoadingWithError(at: 1)
XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once user initiated loading completes with error")
}

func test_loadCommentsCompletion_rendersSuccessfullyLoadedComments() {
func test_loadCommentsCompletion_rendersSuccessfullyLoadedComments() async {
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)
await loader.completeCommentsLoading(with: [comment0], at: 0)
assertThat(sut, isRendering: [comment0])

sut.simulateUserInitiatedReload()
loader.completeCommentsLoading(with: [comment0, comment1], at: 1)
await loader.completeCommentsLoading(with: [comment0, comment1], at: 1)
assertThat(sut, isRendering: [comment0, comment1])
}

func test_loadCommentsCompletion_rendersSuccessfullyLoadedEmptyCommentsAfterNonEmptyComments() {
func test_loadCommentsCompletion_rendersSuccessfullyLoadedEmptyCommentsAfterNonEmptyComments() async {
let comment = makeComment()
let (sut, loader) = makeSUT()

sut.simulateAppearance()
loader.completeCommentsLoading(with: [comment], at: 0)
await loader.completeCommentsLoading(with: [comment], at: 0)
assertThat(sut, isRendering: [comment])

sut.simulateUserInitiatedReload()
loader.completeCommentsLoading(with: [], at: 1)
await loader.completeCommentsLoading(with: [], at: 1)
assertThat(sut, isRendering: [ImageComment]())
}

func test_loadCommentsCompletion_doesNotAlterCurrentRenderingStateOnError() {
func test_loadCommentsCompletion_doesNotAlterCurrentRenderingStateOnError() async {
let comment = makeComment()
let (sut, loader) = makeSUT()

sut.simulateAppearance()
loader.completeCommentsLoading(with: [comment], at: 0)
await loader.completeCommentsLoading(with: [comment], at: 0)
assertThat(sut, isRendering: [comment])

sut.simulateUserInitiatedReload()
loader.completeCommentsLoadingWithError(at: 1)
await loader.completeCommentsLoadingWithError(at: 1)
assertThat(sut, isRendering: [comment])
}

func test_loadCommentsCompletion_rendersErrorMessageOnErrorUntilNextReload() {
func test_loadCommentsCompletion_rendersErrorMessageOnErrorUntilNextReload() async {
let (sut, loader) = makeSUT()

sut.simulateAppearance()
XCTAssertEqual(sut.errorMessage, nil)

loader.completeCommentsLoadingWithError(at: 0)
await loader.completeCommentsLoadingWithError(at: 0)
XCTAssertEqual(sut.errorMessage, loadError)

sut.simulateUserInitiatedReload()
XCTAssertEqual(sut.errorMessage, nil)
}

func test_tapOnErrorView_hidesErrorMessage() {
func test_tapOnErrorView_hidesErrorMessage() async {
let (sut, loader) = makeSUT()

sut.simulateAppearance()
XCTAssertEqual(sut.errorMessage, nil)

loader.completeCommentsLoadingWithError(at: 0)
await loader.completeCommentsLoadingWithError(at: 0)
XCTAssertEqual(sut.errorMessage, loadError)

sut.simulateErrorViewTap()
XCTAssertEqual(sut.errorMessage, nil)
}

func test_deinit_cancelsRunningRequest() {
var cancelCallCount = 0

func test_deinit_cancelsRunningRequest() async throws {
let loader = LoaderSpy<Void, [ImageComment]>()
var sut: ListViewController?

autoreleasepool {
sut = CommentsUIComposer.commentsComposedWith(commentsLoader: {
PassthroughSubject<[ImageComment], Error>()
.handleEvents(receiveCancel: {
cancelCallCount += 1
}).eraseToAnyPublisher()
})

sut = CommentsUIComposer.commentsComposedWith(commentsLoader: loader.loadComments)

sut?.simulateAppearance()
}

XCTAssertEqual(cancelCallCount, 0)
XCTAssertEqual(loader.cancelledCommentsRequestsCount, 0)

sut = nil
let result = try await loader.result(at: 0)

XCTAssertEqual(cancelCallCount, 1)
XCTAssertEqual(result, .cancelled)
XCTAssertEqual(loader.cancelledCommentsRequestsCount, 1)
}

// MARK: - Helpers

private func makeSUT(file: StaticString = #filePath, line: UInt = #line) -> (sut: ListViewController, loader: LoaderSpy) {
let loader = LoaderSpy()
let sut = CommentsUIComposer.commentsComposedWith(commentsLoader: loader.loadPublisher)
private func makeSUT(file: StaticString = #filePath, line: UInt = #line) -> (sut: ListViewController, loader: LoaderSpy<Void, [ImageComment]>) {
let loader = LoaderSpy<Void, [ImageComment]>()
let sut = CommentsUIComposer.commentsComposedWith(commentsLoader: loader.loadComments)
trackForMemoryLeaks(loader, file: file, line: line)
trackForMemoryLeaks(sut, file: file, line: line)

addTeardownBlock { [weak loader] in
try await loader?.cancelPendingRequests()
}

return (sut, loader)
}

Expand All @@ -182,28 +182,27 @@ class CommentsUIIntegrationTests: XCTestCase {
XCTAssertEqual(sut.commentUsername(at: index), comment.username, "username at \(index)", file: file, line: line)
}
}
}

private extension LoaderSpy where Param == Void, Resource == [ImageComment] {
var loadCommentsCallCount: Int {
return requests.count
}

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)
requests[index].send(completion: .finished)
}

func completeCommentsLoadingWithError(at index: Int = 0) {
let error = NSError(domain: "an error", code: 0)
requests[index].send(completion: .failure(error))
}
var cancelledCommentsRequestsCount: Int {
requests.count { $0.result == .cancelled }
}

func loadComments() async throws -> [ImageComment] {
try await load(())
}

func completeCommentsLoading(with comments: [ImageComment] = [], at index: Int = 0) async {
await complete(with: comments, at: index)
}

func completeCommentsLoadingWithError(at index: Int = 0) async {
let error = NSError(domain: "an error", code: 0)
await fail(with: error, at: index)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import Foundation

public struct ImageComment: Equatable {
public struct ImageComment: Equatable, Sendable {
public let id: UUID
public let message: String
public let createdAt: Date
Expand Down