[Framework] Feed List improvements (#19)

This PR contains the work done to improve the overall implementation of the `FeedListViewModel` view model in the `Feed` framework by utilising unit tests.

Reviewed-on: rock-n-code/app-reviews#19
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #19.
This commit is contained in:
2024-03-22 14:44:52 +00:00
committed by javier
parent 050e7e9620
commit c150b88ddc
6 changed files with 919 additions and 102 deletions
@@ -0,0 +1,65 @@
//
// Array+ReviewDTOs.swift
// ReviewsFeed
//
// Created by Javier Cicchelli on 22/03/2024.
// Copyright © 2024 Röck+Cöde. All rights reserved.
//
import Foundation
import ReviewsFeedKit
extension Array where Element == ReviewsFeedKit.Review {
// MARK: Constants
static let none: [ReviewsFeedKit.Review] = []
static let sample: [ReviewsFeedKit.Review] = [
.init(
id: 1,
author: "Some author name #1 here",
title: "Some review title #1 goes here...",
content: "Some long, long, explanatory review comment #1 goes here...",
rating: 3,
version: "v1.0.0",
updated: .init()
),
.init(
id: 2,
author: "Some author name #2 here",
title: "Some review title #2 goes here...",
content: "Some long, long, explanatory review comment #2 goes here...",
rating: 5,
version: "v1.0.0",
updated: .init()
),
.init(
id: 3,
author: "Some author name #3 here",
title: "Some review title #3 goes here...",
content: "Some long, long, explanatory review comment #3 goes here...",
rating: 1,
version: "v1.0.0",
updated: .init()
),
.init(
id: 4,
author: "Some author name #4 here",
title: "Some review title #4 goes here...",
content: "Some long, long, explanatory review comment #4 goes here...",
rating: 4,
version: "v1.0.0",
updated: .init()
),
.init(
id: 5,
author: "Some author name #5 here",
title: "Some review title #5 goes here...",
content: "Some long, long, explanatory review comment #5 goes here...",
rating: 2,
version: "v1.0.0",
updated: .init()
),
]
}
@@ -20,14 +20,14 @@ extension FeedListViewController {
private let topWords: TopWordsUseCase = .init()
// MARK: Properties
@Published var filter: FilterOption = .all
@Published var isFilterEnabled: Bool = false
@Published var isFiltering: Bool = false
@Published var isLoading: Bool = false
@Published var state: FeedListState = .initial
@Published private(set) var filter: FilterOption = .all
@Published private(set) var isFilterEnabled: Bool = false
@Published private(set) var isFiltering: Bool = false
@Published private(set) var isLoading: Bool = false
@Published private(set) var state: FeedListState = .initial
var items: [Review] = []
var words: [TopWord] = []
private(set) var items: [Review] = []
private(set) var words: [TopWord] = []
private var reviewsAll: [Review] = []
private var reviewsFiltered: FilteredReviews = [:]
@@ -57,50 +57,51 @@ extension FeedListViewController {
var isWordsShowing: Bool {
filter != .all
&& !words.isEmpty
&& !words.isEmpty
}
// MARK: Functions
func fetch() {
Task {
isFilterEnabled = false
isLoading = items.isEmpty
func fetch() async {
isFilterEnabled = false
isLoading = items.isEmpty
do {
let output = try await iTunesService.getReviews(.init(
appID: configuration.appID,
countryCode: configuration.countryCode
))
reviewsAll = output.reviews.map(Review.init)
reviewsFiltered = FilterOption.allCases
.reduce(into: FilteredReviews()) { partialResult, option in
partialResult[option] = reviewsAll.filter { $0.rating.stars == option.rawValue }
}
reviewsTopWords = reviewsFiltered
.mapValues { reviews in
reviews.map(\.comment)
.compactMap { try? filterWords($0) }
}
.mapValues {
topWords($0).map(TopWord.init)
}
do {
let output = try await iTunesService.getReviews(.init(
appID: configuration.appID,
countryCode: configuration.countryCode
))
reviewsAll = output.reviews.map(Review.init)
reviewsFiltered = FilterOption.allCases
.reduce(into: FilteredReviews()) { partialResult, option in
partialResult[option] = reviewsAll.filter { $0.rating.stars == option.rawValue }
}
reviewsTopWords = reviewsFiltered
.mapValues { reviews in
reviews.map(\.comment)
.compactMap { try? filterWords($0) }
}
.mapValues {
topWords($0).map(TopWord.init)
}
items = filter == .all
? reviewsAll
: reviewsFiltered[filter] ?? []
items = filter == .all
? reviewsAll
: reviewsFiltered[filter] ?? []
words = filter == .all
? []
: reviewsTopWords[filter] ?? []
isFilterEnabled = !items.isEmpty
state = items.isEmpty
? .empty
: .populated
} catch {
items = []
state = .error
}
isLoading = false
isFilterEnabled = !items.isEmpty
state = items.isEmpty
? .empty
: .populated
} catch {
items = []
state = .error
}
isLoading = false
}
func filter(by option: FilterOption) {
@@ -115,16 +116,19 @@ extension FeedListViewController {
}
func item(for index: Int) -> Review? {
guard
!items.isEmpty,
index < items.count
else {
guard !items.isEmpty else {
return nil
}
return isWordsShowing
? items[index - 1]
: items[index]
let indexToUse = isWordsShowing
? index - 1
: index
guard indexToUse < items.count else {
return nil
}
return items[indexToUse]
}
func openItem(at index: Int) {
@@ -118,7 +118,7 @@ final class FeedListViewController: UIViewController {
registerTableCells()
bindViewModel()
viewModel.fetch()
Task { await viewModel.fetch() }
}
}
@@ -170,7 +170,7 @@ private extension FeedListViewController {
// MARK: Actions
@objc func refresh(_ sender: AnyObject) {
self.viewModel.fetch()
Task { await self.viewModel.fetch() }
}
// MARK: Functions
@@ -264,7 +264,7 @@ private extension FeedListViewController {
)
: nil,
action: isErrorState
? { self.viewModel.fetch() }
? { Task { await self.viewModel.fetch() } }
: nil
)
@@ -455,53 +455,7 @@ import ReviewsiTunesKit
#Preview("Feed List with few reviews") {
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: [
.init(
id: 1,
author: "Some author name #1 here",
title: "Some review title #1 goes here...",
content: "Some long, explanatory review comment #1 goes here...",
rating: 3,
version: "v1.0.0",
updated: .init()
),
.init(
id: 2,
author: "Some author name #2 here",
title: "Some review title #2 goes here...",
content: "Some long, explanatory review comment #2 goes here...",
rating: 5,
version: "v1.0.0",
updated: .init()
),
.init(
id: 3,
author: "Some author name #3 here",
title: "Some review title #3 goes here...",
content: "Some long, explanatory review comment #3 goes here...",
rating: 1,
version: "v1.0.0",
updated: .init()
),
.init(
id: 4,
author: "Some author name #4 here",
title: "Some review title #4 goes here...",
content: "Some long, explanatory review comment #4 goes here...",
rating: 4,
version: "v1.0.0",
updated: .init()
),
.init(
id: 5,
author: "Some author name #5 here",
title: "Some review title #5 goes here...",
content: "Some long, explanatory review comment #5 goes here...",
rating: 2,
version: "v1.0.0",
updated: .init()
),
])
object: Feed(entries: .sample)
)
return UINavigationController(rootViewController: FeedListViewController(.init(
@@ -0,0 +1,21 @@
//
// FeedListCoordinationSpy.swift
// ReviewsFeedTest
//
// Created by Javier Cicchelli on 22/03/2024.
// Copyright © 2024 Röck+Cöde. All rights reserved.
//
@testable import ReviewsFeed
final class FeedListCoordinationSpy: FeedListCoordination {
// MARK: Properties
var itemOpened: Bool = false
// MARK: Functions
func open(_ item: ReviewsFeed.Review) {
itemOpened = true
}
}
@@ -0,0 +1,581 @@
//
// FeedListViewModelTests.swift
// ReviewsFeedTests
//
// Created by Javier Cicchelli on 22/03/2024.
// Copyright © 2024 Röck+Cöde. All rights reserved.
//
import Combine
import ReviewsFoundationKit
import ReviewsiTunesKit
import XCTest
@testable import ReviewsFeed
final class FeedListViewModelTests: XCTestCase {
// MARK: Constants
private let coordination: FeedListCoordinationSpy = .init()
// MARK: Properties
private var sut: FeedListViewController.ViewModel!
private var cancellables: Set<AnyCancellable> = []
// MARK: Setup
override func setUp() async throws {
sut = .init(
configuration: .init(session: .mock),
coordination: coordination
)
}
override func tearDown() async throws {
cancellables.removeAll()
}
// MARK: Initialisers tests
func testInit() {
// GIVEN
// WHEN
// THEN
XCTAssertEqual(sut.filter, .all)
XCTAssertFalse(sut.isFilterEnabled)
XCTAssertFalse(sut.isFiltering)
XCTAssertFalse(sut.isLoading)
XCTAssertFalse(sut.isWordsShowing)
XCTAssertTrue(sut.items.isEmpty)
XCTAssertEqual(sut.itemsCount, 0)
XCTAssertEqual(sut.state, .initial)
XCTAssertTrue(sut.words.isEmpty)
}
// MARK: Functions tests
func testFetch_allItems_whenResponseOK_withSomeItems() async {
let expectation = XCTestExpectation()
var isLoading: [Bool] = []
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .sample)
)
// WHEN
sut.$isLoading
.collect(3)
.sink { value in
isLoading.append(contentsOf: value)
expectation.fulfill()
}
.store(in: &cancellables)
await sut.fetch()
wait(for: [expectation], timeout: 2)
// THEN
XCTAssertEqual(isLoading, [false, true, false])
XCTAssertTrue(sut.isFilterEnabled)
XCTAssertFalse(sut.isWordsShowing)
XCTAssertFalse(sut.items.isEmpty)
XCTAssertEqual(sut.items.count, 5)
XCTAssertEqual(sut.itemsCount, 5)
XCTAssertEqual(sut.state, .populated)
}
func testFetch_allItems_whenResponseOK_withNoItems() async {
let expectation = XCTestExpectation()
var isLoading: [Bool] = []
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .none)
)
// WHEN
sut.$isLoading
.collect(3)
.sink { value in
isLoading.append(contentsOf: value)
expectation.fulfill()
}
.store(in: &cancellables)
await sut.fetch()
wait(for: [expectation], timeout: 2)
// THEN
XCTAssertEqual(isLoading, [false, true, false])
XCTAssertFalse(sut.isFilterEnabled)
XCTAssertFalse(sut.isWordsShowing)
XCTAssertTrue(sut.items.isEmpty)
XCTAssertEqual(sut.itemsCount, 0)
XCTAssertEqual(sut.state, .empty)
}
func testFetch_allItems_whenResponseNotOK() async {
let expectation = XCTestExpectation()
var isLoading: [Bool] = []
// GIVEN
MockURLProtocol.response = .init(statusCode: 404)
// WHEN
sut.$isLoading
.collect(3)
.sink { value in
isLoading.append(contentsOf: value)
expectation.fulfill()
}
.store(in: &cancellables)
await sut.fetch()
wait(for: [expectation], timeout: 2)
// THEN
XCTAssertEqual(isLoading, [false, true, false])
XCTAssertFalse(sut.isFilterEnabled)
XCTAssertFalse(sut.isWordsShowing)
XCTAssertTrue(sut.items.isEmpty)
XCTAssertEqual(sut.itemsCount, 0)
XCTAssertEqual(sut.state, .error)
}
func testFetch_filteredItems_whenResponseOK_withSomeItems() async {
let expectation = XCTestExpectation()
var isLoading: [Bool] = []
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .sample)
)
// WHEN
sut.$isLoading
.collect(3)
.sink { value in
isLoading.append(contentsOf: value)
expectation.fulfill()
}
.store(in: &cancellables)
sut.filter(by: .only1Star)
await sut.fetch()
wait(for: [expectation], timeout: 2)
// THEN
XCTAssertEqual(isLoading, [false, true, false])
XCTAssertEqual(sut.filter, .only1Star)
XCTAssertTrue(sut.isFilterEnabled)
XCTAssertTrue(sut.isWordsShowing)
XCTAssertFalse(sut.items.isEmpty)
XCTAssertEqual(sut.items.count, 1)
XCTAssertEqual(sut.itemsCount, 2)
XCTAssertEqual(sut.state, .populated)
XCTAssertFalse(sut.words.isEmpty)
XCTAssertEqual(sut.words.count, 3)
}
func testFetch_filteredItems_whenResponseOK_withNoItems() async {
let expectation = XCTestExpectation()
var isLoading: [Bool] = []
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .none)
)
// WHEN
sut.$isLoading
.collect(3)
.sink { value in
isLoading.append(contentsOf: value)
expectation.fulfill()
}
.store(in: &cancellables)
sut.filter(by: .only1Star)
await sut.fetch()
wait(for: [expectation], timeout: 2)
// THEN
XCTAssertEqual(isLoading, [false, true, false])
XCTAssertEqual(sut.filter, .only1Star)
XCTAssertFalse(sut.isFilterEnabled)
XCTAssertFalse(sut.isWordsShowing)
XCTAssertTrue(sut.items.isEmpty)
XCTAssertEqual(sut.itemsCount, 0)
XCTAssertEqual(sut.state, .empty)
XCTAssertTrue(sut.words.isEmpty)
}
func testFetch_filteredItems_whenResponseNotOK() async {
let expectation = XCTestExpectation()
var isLoading: [Bool] = []
// GIVEN
MockURLProtocol.response = .init(statusCode: 404)
// WHEN
sut.$isLoading
.collect(3)
.sink { value in
isLoading.append(contentsOf: value)
expectation.fulfill()
}
.store(in: &cancellables)
sut.filter(by: .only1Star)
await sut.fetch()
wait(for: [expectation], timeout: 2)
// THEN
XCTAssertEqual(isLoading, [false, true, false])
XCTAssertEqual(sut.filter, .only1Star)
XCTAssertFalse(sut.isFilterEnabled)
XCTAssertFalse(sut.isWordsShowing)
XCTAssertTrue(sut.items.isEmpty)
XCTAssertEqual(sut.itemsCount, 0)
XCTAssertEqual(sut.state, .error)
XCTAssertTrue(sut.words.isEmpty)
}
func testFilter_forNewOption_withSomeItems() async {
let expectation = XCTestExpectation()
var filter: [FilterOption] = []
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .sample)
)
// WHEN
sut.$filter
.dropFirst()
.sink { value in
filter.append(value)
expectation.fulfill()
}
.store(in: &cancellables)
await sut.fetch()
sut.filter(by: .only1Star)
wait(for: [expectation], timeout: 2)
// THEN
XCTAssertEqual(filter, [.only1Star])
XCTAssertFalse(sut.items.isEmpty)
XCTAssertEqual(sut.items.count, 1)
XCTAssertEqual(sut.itemsCount, 2)
XCTAssertFalse(sut.words.isEmpty)
XCTAssertEqual(sut.words.count, 3)
}
func testFilter_forNewOption_withNoItems() async {
let expectation = XCTestExpectation()
var filter: [FilterOption] = []
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .none)
)
// WHEN
sut.$filter
.dropFirst()
.sink { value in
filter.append(value)
expectation.fulfill()
}
.store(in: &cancellables)
await sut.fetch()
sut.filter(by: .only1Star)
wait(for: [expectation], timeout: 2)
// THEN
XCTAssertEqual(filter, [.only1Star])
XCTAssertTrue(sut.items.isEmpty)
XCTAssertEqual(sut.items.count, 0)
XCTAssertEqual(sut.itemsCount, 0)
XCTAssertTrue(sut.words.isEmpty)
XCTAssertEqual(sut.words.count, 0)
}
func testFilter_forSameOption_withSomeItems() async {
let expectation = XCTestExpectation()
var filter: [FilterOption] = []
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .sample)
)
// WHEN
sut.$filter
.dropFirst()
.sink { value in
filter.append(value)
expectation.fulfill()
}
.store(in: &cancellables)
await sut.fetch()
sut.filter(by: .only1Star)
sut.filter(by: .only1Star)
wait(for: [expectation], timeout: 2)
// THEN
XCTAssertEqual(filter, [.only1Star])
XCTAssertFalse(sut.items.isEmpty)
XCTAssertEqual(sut.items.count, 1)
XCTAssertEqual(sut.itemsCount, 2)
XCTAssertFalse(sut.words.isEmpty)
XCTAssertEqual(sut.words.count, 3)
}
func testFilter_forSameOption_withNoItems() async {
let expectation = XCTestExpectation()
var filter: [FilterOption] = []
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .none)
)
// WHEN
sut.$filter
.dropFirst()
.sink { value in
filter.append(value)
expectation.fulfill()
}
.store(in: &cancellables)
await sut.fetch()
sut.filter(by: .only1Star)
wait(for: [expectation], timeout: 2)
// THEN
XCTAssertEqual(filter, [.only1Star])
XCTAssertTrue(sut.items.isEmpty)
XCTAssertEqual(sut.items.count, 0)
XCTAssertEqual(sut.itemsCount, 0)
XCTAssertTrue(sut.words.isEmpty)
XCTAssertEqual(sut.words.count, 0)
}
func testItemFor_index_withSomeItems() async {
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .sample)
)
// WHEN
await sut.fetch()
let item = sut.item(for: 0)
// THEN
XCTAssertNotNil(item)
}
func testItemFor_indexOutOfBounds_withSomeItems() async {
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .sample)
)
// WHEN
await sut.fetch()
let item = sut.item(for: 10)
// THEN
XCTAssertNil(item)
}
func testItemFor_index_withFilteredItems() async {
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .sample)
)
// WHEN
await sut.fetch()
sut.filter(by: .only1Star)
let item = sut.item(for: 1)
// THEN
XCTAssertNotNil(item)
}
func testItemFor_indexOutOfBounds_withFilteredItems() async {
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .sample)
)
// WHEN
await sut.fetch()
sut.filter(by: .only1Star)
let item = sut.item(for: 2)
// THEN
XCTAssertNil(item)
}
func testItemFor_index_withNoItems() async {
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .none)
)
// WHEN
await sut.fetch()
let item = sut.item(for: 0)
// THEN
XCTAssertNil(item)
}
func testOpenItemAt_index_withSomeItems() async {
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .sample)
)
// WHEN
await sut.fetch()
sut.openItem(at: 0)
// THEN
XCTAssertTrue(coordination.itemOpened)
}
func testOpenItemAt_indexOutOfBounds_withSomeItems() async {
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .sample)
)
// WHEN
await sut.fetch()
sut.openItem(at: 10)
// THEN
XCTAssertFalse(coordination.itemOpened)
}
func testOpenItemAt_index_withFilteredItems() async {
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .sample)
)
// WHEN
sut.filter(by: .only1Star)
await sut.fetch()
sut.openItem(at: 1)
// THEN
XCTAssertTrue(coordination.itemOpened)
}
func testOpenItemAt_indexOutOfBounds_withFilteredItems() async {
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .sample)
)
// WHEN
sut.filter(by: .only1Star)
await sut.fetch()
sut.openItem(at: 2)
// THEN
XCTAssertFalse(coordination.itemOpened)
}
func testOpenItemAt_index_withNoItems() async {
// GIVEN
MockURLProtocol.response = .init(
statusCode: 200,
object: Feed(entries: .none)
)
// WHEN
await sut.fetch()
sut.openItem(at: 0)
// THEN
XCTAssertFalse(coordination.itemOpened)
}
}