diff --git a/Frameworks/Feed/Bundle/Previews/Extensions/Array+ReviewDTOs.swift b/Frameworks/Feed/Bundle/Previews/Extensions/Array+ReviewDTOs.swift new file mode 100644 index 0000000..7a345f1 --- /dev/null +++ b/Frameworks/Feed/Bundle/Previews/Extensions/Array+ReviewDTOs.swift @@ -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() + ), + ] + +} diff --git a/Frameworks/Feed/Bundle/Sources/Logic/View Models/FeedListViewModel.swift b/Frameworks/Feed/Bundle/Sources/Logic/View Models/FeedListViewModel.swift index c561eac..f853281 100644 --- a/Frameworks/Feed/Bundle/Sources/Logic/View Models/FeedListViewModel.swift +++ b/Frameworks/Feed/Bundle/Sources/Logic/View Models/FeedListViewModel.swift @@ -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) { diff --git a/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift b/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift index a9e662f..152a5ed 100644 --- a/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift +++ b/Frameworks/Feed/Bundle/Sources/UI/View Controllers/FeedListViewController.swift @@ -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( diff --git a/Frameworks/Feed/Test/Helpers/Spies/Coordination/FeedListCoordinationSpy.swift b/Frameworks/Feed/Test/Helpers/Spies/Coordination/FeedListCoordinationSpy.swift new file mode 100644 index 0000000..faae101 --- /dev/null +++ b/Frameworks/Feed/Test/Helpers/Spies/Coordination/FeedListCoordinationSpy.swift @@ -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 + } + +} diff --git a/Frameworks/Feed/Test/Tests/View Models/FeedListViewModelTests.swift b/Frameworks/Feed/Test/Tests/View Models/FeedListViewModelTests.swift new file mode 100644 index 0000000..6c13227 --- /dev/null +++ b/Frameworks/Feed/Test/Tests/View Models/FeedListViewModelTests.swift @@ -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 = [] + + // 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) + } + +} diff --git a/Reviews.xcodeproj/project.pbxproj b/Reviews.xcodeproj/project.pbxproj index 0a47e76..654ef4f 100644 --- a/Reviews.xcodeproj/project.pbxproj +++ b/Reviews.xcodeproj/project.pbxproj @@ -18,6 +18,10 @@ 02909E792BAB6B0200710E14 /* FilterOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02909E782BAB6B0200710E14 /* FilterOption.swift */; }; 02909E7B2BAB6D2E00710E14 /* Bundle+Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02909E7A2BAB6D2E00710E14 /* Bundle+Constants.swift */; }; 02909E7D2BAB7FFE00710E14 /* Review+DTOs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02909E7C2BAB7FFE00710E14 /* Review+DTOs.swift */; }; + 02B36F7D2BAD9D1A00F1A89D /* ReviewsFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02DC7F8F2BA51793000EEEBE /* ReviewsFeed.framework */; platformFilter = ios; }; + 02B36F852BAD9DDB00F1A89D /* FeedListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B36F842BAD9DDB00F1A89D /* FeedListViewModelTests.swift */; }; + 02B36F892BADB26C00F1A89D /* Array+ReviewDTOs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B36F882BADB26C00F1A89D /* Array+ReviewDTOs.swift */; }; + 02B36F8F2BADCABC00F1A89D /* FeedListCoordinationSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B36F8D2BADCA8000F1A89D /* FeedListCoordinationSpy.swift */; }; 02C1B1972BAC9BFE001781DE /* FeedListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C1B1962BAC9BFE001781DE /* FeedListCoordinator.swift */; }; 02C1B1A92BACA722001781DE /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C1B1A82BACA722001781DE /* AppCoordinator.swift */; }; 02DA924E2BAAE3FD00C47985 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 02DA924D2BAAE3FD00C47985 /* Localizable.xcstrings */; }; @@ -40,6 +44,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 02B36F7E2BAD9D1A00F1A89D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 345AD11024C6EDD9004E2EE1 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 02DC7F8E2BA51793000EEEBE; + remoteInfo = Feed; + }; 02DC7FA02BA51793000EEEBE /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 345AD11024C6EDD9004E2EE1 /* Project object */; @@ -75,6 +86,10 @@ 02909E782BAB6B0200710E14 /* FilterOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterOption.swift; sourceTree = ""; }; 02909E7A2BAB6D2E00710E14 /* Bundle+Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Constants.swift"; sourceTree = ""; }; 02909E7C2BAB7FFE00710E14 /* Review+DTOs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Review+DTOs.swift"; sourceTree = ""; }; + 02B36F792BAD9D1A00F1A89D /* FeedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FeedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 02B36F842BAD9DDB00F1A89D /* FeedListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListViewModelTests.swift; sourceTree = ""; }; + 02B36F882BADB26C00F1A89D /* Array+ReviewDTOs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+ReviewDTOs.swift"; sourceTree = ""; }; + 02B36F8D2BADCA8000F1A89D /* FeedListCoordinationSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListCoordinationSpy.swift; sourceTree = ""; }; 02C1B1962BAC9BFE001781DE /* FeedListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListCoordinator.swift; sourceTree = ""; }; 02C1B1A82BACA722001781DE /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; 02DA924D2BAAE3FD00C47985 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; @@ -97,6 +112,14 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 02B36F762BAD9D1A00F1A89D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 02B36F7D2BAD9D1A00F1A89D /* ReviewsFeed.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 02DC7F8C2BA51793000EEEBE /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -217,6 +240,7 @@ 02DC7F912BA51793000EEEBE /* ReviewsFeed.h */, 02DA924B2BAAE3E500C47985 /* Resources */, 02DC7FB02BA51B4F000EEEBE /* Sources */, + 02B36F862BADB1FD00F1A89D /* Previews */, ); path = Bundle; sourceTree = ""; @@ -224,10 +248,68 @@ 02A6DA302BA5929F00B943E2 /* Test */ = { isa = PBXGroup; children = ( + 02B36F8A2BADCA5B00F1A89D /* Helpers */, + 02B36F742BAD9C4500F1A89D /* Tests */, ); path = Test; sourceTree = ""; }; + 02B36F742BAD9C4500F1A89D /* Tests */ = { + isa = PBXGroup; + children = ( + 02B36F832BAD9DC700F1A89D /* View Models */, + ); + path = Tests; + sourceTree = ""; + }; + 02B36F832BAD9DC700F1A89D /* View Models */ = { + isa = PBXGroup; + children = ( + 02B36F842BAD9DDB00F1A89D /* FeedListViewModelTests.swift */, + ); + path = "View Models"; + sourceTree = ""; + }; + 02B36F862BADB1FD00F1A89D /* Previews */ = { + isa = PBXGroup; + children = ( + 02B36F872BADB23200F1A89D /* Extensions */, + ); + path = Previews; + sourceTree = ""; + }; + 02B36F872BADB23200F1A89D /* Extensions */ = { + isa = PBXGroup; + children = ( + 02B36F882BADB26C00F1A89D /* Array+ReviewDTOs.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 02B36F8A2BADCA5B00F1A89D /* Helpers */ = { + isa = PBXGroup; + children = ( + 02B36F8B2BADCA6200F1A89D /* Spies */, + ); + path = Helpers; + sourceTree = ""; + }; + 02B36F8B2BADCA6200F1A89D /* Spies */ = { + isa = PBXGroup; + children = ( + 02B36F8C2BADCA6A00F1A89D /* Coordination */, + ); + path = Spies; + sourceTree = ""; + }; + 02B36F8C2BADCA6A00F1A89D /* Coordination */ = { + isa = PBXGroup; + children = ( + 02B36F8D2BADCA8000F1A89D /* FeedListCoordinationSpy.swift */, + ); + path = Coordination; + sourceTree = ""; + }; 02C1B1952BAC9BE7001781DE /* Coordinators */ = { isa = PBXGroup; children = ( @@ -364,6 +446,7 @@ children = ( 345AD11824C6EDD9004E2EE1 /* Reviews.app */, 02DC7F8F2BA51793000EEEBE /* ReviewsFeed.framework */, + 02B36F792BAD9D1A00F1A89D /* FeedTests.xctest */, ); name = Products; sourceTree = ""; @@ -391,6 +474,24 @@ /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ + 02B36F782BAD9D1A00F1A89D /* FeedTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 02B36F802BAD9D1A00F1A89D /* Build configuration list for PBXNativeTarget "FeedTests" */; + buildPhases = ( + 02B36F752BAD9D1A00F1A89D /* Sources */, + 02B36F762BAD9D1A00F1A89D /* Frameworks */, + 02B36F772BAD9D1A00F1A89D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 02B36F7F2BAD9D1A00F1A89D /* PBXTargetDependency */, + ); + name = FeedTests; + productName = ReviewsFeedTests; + productReference = 02B36F792BAD9D1A00F1A89D /* FeedTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 02DC7F8E2BA51793000EEEBE /* Feed */ = { isa = PBXNativeTarget; buildConfigurationList = 02DC7FA92BA51793000EEEBE /* Build configuration list for PBXNativeTarget "Feed" */; @@ -445,6 +546,9 @@ LastUpgradeCheck = 1530; ORGANIZATIONNAME = "Röck+Cöde"; TargetAttributes = { + 02B36F782BAD9D1A00F1A89D = { + CreatedOnToolsVersion = 15.3; + }; 02DC7F8E2BA51793000EEEBE = { CreatedOnToolsVersion = 15.3; }; @@ -466,13 +570,21 @@ projectDirPath = ""; projectRoot = ""; targets = ( - 02DC7F8E2BA51793000EEEBE /* Feed */, 345AD11724C6EDD9004E2EE1 /* App */, + 02DC7F8E2BA51793000EEEBE /* Feed */, + 02B36F782BAD9D1A00F1A89D /* FeedTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 02B36F772BAD9D1A00F1A89D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 02DC7F8D2BA51793000EEEBE /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -493,11 +605,21 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 02B36F752BAD9D1A00F1A89D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 02B36F852BAD9DDB00F1A89D /* FeedListViewModelTests.swift in Sources */, + 02B36F8F2BADCABC00F1A89D /* FeedListCoordinationSpy.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 02DC7F8B2BA51793000EEEBE /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 02EACF302BABA50D00FF8ECD /* TopWordsCell.swift in Sources */, + 02B36F892BADB26C00F1A89D /* Array+ReviewDTOs.swift in Sources */, 02620B8C2BA89C9A00DE7137 /* FeedListViewModel.swift in Sources */, 02EACF342BABB28900FF8ECD /* TopWord.swift in Sources */, 023AC7FC2BAA3EC10027D064 /* Int+Constants.swift in Sources */, @@ -532,6 +654,12 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 02B36F7F2BAD9D1A00F1A89D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = 02DC7F8E2BA51793000EEEBE /* Feed */; + targetProxy = 02B36F7E2BAD9D1A00F1A89D /* PBXContainerItemProxy */; + }; 02DC7FA12BA51793000EEEBE /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 02DC7F8E2BA51793000EEEBE /* Feed */; @@ -551,6 +679,59 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 02B36F812BAD9D1A00F1A89D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 7FMNM89WKG; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.rock-n-code.assignment.ing.framework.feed.test.unit"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 02B36F822BAD9D1A00F1A89D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 7FMNM89WKG; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.rock-n-code.assignment.ing.framework.feed.test.unit"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; 02DC7FA52BA51793000EEEBE /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -562,6 +743,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; + DEVELOPMENT_ASSET_PATHS = Frameworks/Feed/Bundle/Previews; DEVELOPMENT_TEAM = 7FMNM89WKG; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -611,6 +793,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; + DEVELOPMENT_ASSET_PATHS = Frameworks/Feed/Bundle/Previews; DEVELOPMENT_TEAM = 7FMNM89WKG; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -825,6 +1008,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 02B36F802BAD9D1A00F1A89D /* Build configuration list for PBXNativeTarget "FeedTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 02B36F812BAD9D1A00F1A89D /* Debug */, + 02B36F822BAD9D1A00F1A89D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 02DC7FA92BA51793000EEEBE /* Build configuration list for PBXNativeTarget "Feed" */ = { isa = XCConfigurationList; buildConfigurations = (