Skip to content

Commit 6b1077b

Browse files
authored
Merge pull request #2203 from woocommerce/issue/2171-refactor-order-starter-vm
Refactor OrderSearchStarterViewModel for Testability
2 parents b03282f + e2d9e25 commit 6b1077b

File tree

4 files changed

+187
-72
lines changed

4 files changed

+187
-72
lines changed

WooCommerce/Classes/ViewRelated/Search/Order/OrderSearchStarterViewController.swift

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,45 @@ final class OrderSearchStarterViewController: UIViewController, KeyboardFrameAdj
3636

3737
configureTableView()
3838

39-
viewModel.activate(using: tableView)
39+
viewModel.activateAndForwardUpdates(to: tableView)
4040
}
4141

4242
private func configureTableView() {
43+
tableView.register(BasicTableViewCell.loadNib(),
44+
forCellReuseIdentifier: BasicTableViewCell.reuseIdentifier)
45+
4346
tableView.backgroundColor = .listBackground
4447
tableView.delegate = self
48+
tableView.dataSource = self
4549

4650
keyboardFrameObserver.startObservingKeyboardFrame()
4751
}
4852
}
4953

54+
// MARK: - UITableViewDataSource
55+
56+
extension OrderSearchStarterViewController: UITableViewDataSource {
57+
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
58+
viewModel.numberOfObjects
59+
}
60+
61+
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
62+
let cell = tableView.dequeueReusableCell(withIdentifier: BasicTableViewCell.reuseIdentifier,
63+
for: indexPath)
64+
let orderStatus = viewModel.orderStatus(at: indexPath)
65+
66+
cell.accessoryType = .disclosureIndicator
67+
cell.selectionStyle = .default
68+
cell.textLabel?.text = orderStatus.name
69+
70+
return cell
71+
}
72+
73+
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
74+
NSLocalizedString("Order Status", comment: "The section title for the list of Order statuses in the Order Search.")
75+
}
76+
}
77+
5078
// MARK: - UITableViewDelegate
5179

5280
extension OrderSearchStarterViewController: UITableViewDelegate {

WooCommerce/Classes/ViewRelated/Search/Order/OrderSearchStarterViewModel.swift

Lines changed: 39 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,30 @@ import Foundation
33
import UIKit
44
import Yosemite
55

6+
import class AutomatticTracks.CrashLogging
7+
import protocol Storage.StorageManagerType
8+
69
/// ViewModel for `OrderSearchStarterViewController`.
710
///
811
/// This encapsulates all the `OrderStatus` data loading and `UITableViewCell` presentation.
912
///
1013
final class OrderSearchStarterViewModel {
11-
private lazy var dataSource = DataSource()
14+
private let siteID: Int64
15+
private let storageManager: StorageManagerType
16+
17+
private lazy var resultsController: ResultsController<StorageOrderStatus> = {
18+
let descriptor = NSSortDescriptor(key: "slug", ascending: true)
19+
let predicate = NSPredicate(format: "siteID == %lld", siteID)
20+
return ResultsController<StorageOrderStatus>(storageManager: storageManager,
21+
matching: predicate,
22+
sortedBy: [descriptor])
23+
}()
24+
25+
init(siteID: Int64 = ServiceLocator.stores.sessionManager.defaultStoreID ?? Int64.min,
26+
storageManager: StorageManagerType = ServiceLocator.storageManager) {
27+
self.siteID = siteID
28+
self.storageManager = storageManager
29+
}
1230

1331
/// Start all the operations that this `ViewModel` is responsible for.
1432
///
@@ -17,85 +35,35 @@ final class OrderSearchStarterViewModel {
1735
/// - Parameters:
1836
/// - tableView: The table to use for the results. This is not retained by this class.
1937
///
20-
func activate(using tableView: UITableView) {
21-
tableView.dataSource = dataSource
38+
func activateAndForwardUpdates(to tableView: UITableView) {
39+
resultsController.startForwardingEvents(to: tableView)
2240

23-
dataSource.registerCells(for: tableView)
24-
dataSource.startForwardingEvents(to: tableView)
25-
26-
try? dataSource.performFetch()
41+
performFetch()
2742
}
2843

29-
/// The `OrderStatus` located at `indexPath`.
44+
/// Fetch and log the error if there's any.
3045
///
31-
func orderStatus(at indexPath: IndexPath) -> OrderStatus {
32-
dataSource.orderStatus(at: indexPath)
46+
private func performFetch() {
47+
do {
48+
try resultsController.performFetch()
49+
} catch {
50+
CrashLogging.logError(error)
51+
}
3352
}
3453
}
3554

55+
// MARK: - TableView Support
3656

37-
private extension OrderSearchStarterViewModel {
38-
/// Encpsulates data loading and presentation of the `UITableViewCells`.
57+
extension OrderSearchStarterViewModel {
58+
/// The number of DB results
3959
///
40-
final class DataSource: NSObject, UITableViewDataSource {
41-
private let storageManager = ServiceLocator.storageManager
42-
private let siteID = ServiceLocator.stores.sessionManager.defaultStoreID ?? Int64.min
43-
44-
private lazy var resultsController: ResultsController<StorageOrderStatus> = {
45-
let descriptor = NSSortDescriptor(key: "slug", ascending: true)
46-
let predicate = NSPredicate(format: "siteID == %lld", siteID)
47-
return ResultsController<StorageOrderStatus>(storageManager: storageManager,
48-
matching: predicate,
49-
sortedBy: [descriptor])
50-
}()
51-
52-
/// Run the query to fetch all the `OrderStatus`.
53-
///
54-
func performFetch() throws {
55-
try resultsController.performFetch()
56-
}
57-
58-
/// Attach events so the `tableView` is always kept up to date.
59-
///
60-
/// This should only be called once.
61-
///
62-
func startForwardingEvents(to tableView: UITableView) {
63-
resultsController.startForwardingEvents(to: tableView)
64-
}
65-
66-
/// Register the `UITableViewCells` that will be used.
67-
///
68-
/// This should only be called once.
69-
///
70-
func registerCells(for tableView: UITableView) {
71-
tableView.register(BasicTableViewCell.loadNib(),
72-
forCellReuseIdentifier: BasicTableViewCell.reuseIdentifier)
73-
}
74-
75-
/// The `OrderStatus` located at `indexPath`.
76-
///
77-
func orderStatus(at indexPath: IndexPath) -> OrderStatus {
78-
resultsController.object(at: indexPath)
79-
}
80-
81-
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
82-
resultsController.numberOfObjects
83-
}
84-
85-
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
86-
let cell = tableView.dequeueReusableCell(withIdentifier: BasicTableViewCell.reuseIdentifier,
87-
for: indexPath)
88-
let orderStatus = self.orderStatus(at: indexPath)
89-
90-
cell.accessoryType = .disclosureIndicator
91-
cell.selectionStyle = .default
92-
cell.textLabel?.text = orderStatus.name
93-
94-
return cell
95-
}
60+
var numberOfObjects: Int {
61+
resultsController.numberOfObjects
62+
}
9663

97-
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
98-
NSLocalizedString("Order Status", comment: "The section title for the list of Order statuses in the Order Search.")
99-
}
64+
/// The `OrderStatus` located at `indexPath`.
65+
///
66+
func orderStatus(at indexPath: IndexPath) -> OrderStatus {
67+
resultsController.object(at: indexPath)
10068
}
10169
}

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@
306306
45FBDF39238D3F8800127F77 /* ExtendedAddProductImageCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 45FBDF36238D3C7500127F77 /* ExtendedAddProductImageCollectionViewCell.xib */; };
307307
45FBDF3A238D3F8B00127F77 /* ExtendedAddProductImageCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBDF35238D3C7500127F77 /* ExtendedAddProductImageCollectionViewCell.swift */; };
308308
45FBDF3C238D4EA800127F77 /* ExtendedAddProductImageCollectionViewCellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBDF3B238D4EA800127F77 /* ExtendedAddProductImageCollectionViewCellTests.swift */; };
309+
573D0ACF2458665C004DE614 /* OrderSearchStarterViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 573D0ACE2458665C004DE614 /* OrderSearchStarterViewModelTests.swift */; };
309310
57448D28242E775000A56A74 /* EmptySearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57448D27242E775000A56A74 /* EmptySearchResultsViewController.swift */; };
310311
57448D2A242E777700A56A74 /* EmptySearchResultsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 57448D29242E777700A56A74 /* EmptySearchResultsViewController.xib */; };
311312
5754727B2451F14600A94C3C /* Observable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5754727A2451F14600A94C3C /* Observable.swift */; };
@@ -1128,6 +1129,7 @@
11281129
45FBDF35238D3C7500127F77 /* ExtendedAddProductImageCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedAddProductImageCollectionViewCell.swift; sourceTree = "<group>"; };
11291130
45FBDF36238D3C7500127F77 /* ExtendedAddProductImageCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ExtendedAddProductImageCollectionViewCell.xib; sourceTree = "<group>"; };
11301131
45FBDF3B238D4EA800127F77 /* ExtendedAddProductImageCollectionViewCellTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedAddProductImageCollectionViewCellTests.swift; sourceTree = "<group>"; };
1132+
573D0ACE2458665C004DE614 /* OrderSearchStarterViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OrderSearchStarterViewModelTests.swift; sourceTree = "<group>"; };
11311133
57448D27242E775000A56A74 /* EmptySearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptySearchResultsViewController.swift; sourceTree = "<group>"; };
11321134
57448D29242E777700A56A74 /* EmptySearchResultsViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = EmptySearchResultsViewController.xib; sourceTree = "<group>"; };
11331135
5754727A2451F14600A94C3C /* Observable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observable.swift; sourceTree = "<group>"; };
@@ -2341,6 +2343,22 @@
23412343
path = "Collection View Cells";
23422344
sourceTree = "<group>";
23432345
};
2346+
573D0ACC2458665C004DE614 /* Search */ = {
2347+
isa = PBXGroup;
2348+
children = (
2349+
573D0ACD2458665C004DE614 /* Order */,
2350+
);
2351+
path = Search;
2352+
sourceTree = "<group>";
2353+
};
2354+
573D0ACD2458665C004DE614 /* Order */ = {
2355+
isa = PBXGroup;
2356+
children = (
2357+
573D0ACE2458665C004DE614 /* OrderSearchStarterViewModelTests.swift */,
2358+
);
2359+
path = Order;
2360+
sourceTree = "<group>";
2361+
};
23442362
57448D26242E772300A56A74 /* EmptySearchResultsViewController */ = {
23452363
isa = PBXGroup;
23462364
children = (
@@ -3627,6 +3645,7 @@
36273645
0269576B237263F3001BA0BF /* Keyboard */,
36283646
02E4FD7F2306AA770049610C /* Dashboard */,
36293647
0269177E23260090002AFC20 /* Products */,
3648+
573D0ACC2458665C004DE614 /* Search */,
36303649
D85B833E2230F268002168F3 /* SummaryTableViewCellTests.swift */,
36313650
D85B8335222FCDA1002168F3 /* StatusListTableViewCellTests.swift */,
36323651
D816DDBB22265DA300903E59 /* OrderTrackingTableViewCellTests.swift */,
@@ -4865,6 +4884,7 @@
48654884
0290E27E238E5B5C00B5C466 /* ProductStockStatusListSelectorDataSourceTests.swift in Sources */,
48664885
020B2F9123BDD71500BD79AD /* IntegerInputFormatterTests.swift in Sources */,
48674886
D816DDBC22265DA300903E59 /* OrderTrackingTableViewCellTests.swift in Sources */,
4887+
573D0ACF2458665C004DE614 /* OrderSearchStarterViewModelTests.swift in Sources */,
48684888
D85136DD231E613900DD0539 /* ReviewsViewModelTests.swift in Sources */,
48694889
D85B833D2230DC9D002168F3 /* StringWooTests.swift in Sources */,
48704890
D8736B5122EB69E300A14A29 /* OrderDetailsViewModelTests.swift in Sources */,
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
2+
import XCTest
3+
import Foundation
4+
import Yosemite
5+
import Storage
6+
7+
@testable import WooCommerce
8+
9+
/// Tests for `OrderSearchStarterViewModel`
10+
///
11+
final class OrderSearchStarterViewModelTests: XCTestCase {
12+
private let siteID: Int64 = 1_000_000
13+
14+
private var storageManager: StorageManagerType!
15+
16+
private var storage: StorageType {
17+
storageManager.viewStorage
18+
}
19+
20+
override func setUp() {
21+
super.setUp()
22+
storageManager = MockupStorageManager()
23+
}
24+
25+
override func tearDown() {
26+
storageManager = nil
27+
super.tearDown()
28+
}
29+
30+
func testItLoadsAllTheOrderStatusForTheGivenSite() {
31+
// Given
32+
let viewModel = OrderSearchStarterViewModel(siteID: siteID, storageManager: storageManager)
33+
34+
let expectedItems = [
35+
insertOrderStatus(siteID: siteID, status: .completed),
36+
insertOrderStatus(siteID: siteID, status: .failed),
37+
insertOrderStatus(siteID: siteID, status: .processing),
38+
]
39+
40+
let unexpectedItem = insertOrderStatus(siteID: 511_315, status: .pending)
41+
42+
// When
43+
viewModel.activateAndForwardUpdates(to: UITableView())
44+
45+
// Then
46+
XCTAssertEqual(viewModel.numberOfObjects, expectedItems.count)
47+
XCTAssertEqual(viewModel.fetchedOrderStatuses, expectedItems)
48+
XCTAssertFalse(viewModel.fetchedOrderStatuses.contains(where: { $0.siteID != siteID }))
49+
XCTAssertFalse(viewModel.fetchedOrderStatuses.contains(unexpectedItem))
50+
}
51+
52+
func testItSortsTheOrderStatusesBySlug() {
53+
// Given
54+
let viewModel = OrderSearchStarterViewModel(siteID: siteID, storageManager: storageManager)
55+
56+
insertOrderStatus(siteID: siteID, status: .completed, slug: "delta")
57+
insertOrderStatus(siteID: siteID, status: .processing, slug: "charlie")
58+
insertOrderStatus(siteID: siteID, status: .failed, slug: "echo")
59+
insertOrderStatus(siteID: siteID, status: .cancelled, slug: "alpha")
60+
insertOrderStatus(siteID: siteID, status: .cancelled, slug: "beta")
61+
62+
// When
63+
viewModel.activateAndForwardUpdates(to: UITableView())
64+
65+
// Then
66+
let expectedSlugs = ["alpha", "beta", "charlie", "delta", "echo"]
67+
let actualSlugs = viewModel.fetchedOrderStatuses.map(\.slug)
68+
XCTAssertEqual(actualSlugs, expectedSlugs)
69+
}
70+
}
71+
72+
// MARK: - Helpers
73+
74+
private extension OrderSearchStarterViewModel {
75+
/// Returns the `OrderStatus` for all the rows
76+
///
77+
var fetchedOrderStatuses: [Yosemite.OrderStatus] {
78+
(0..<numberOfObjects).map { orderStatus(at: IndexPath(row: $0, section: 0)) }
79+
}
80+
}
81+
82+
// MARK: - Fixtures
83+
84+
private extension OrderSearchStarterViewModelTests {
85+
@discardableResult
86+
func insertOrderStatus(siteID: Int64,
87+
status: OrderStatusEnum,
88+
slug: String? = nil) -> Yosemite.OrderStatus {
89+
let readOnlyOrderStatus = OrderStatus(name: status.rawValue,
90+
siteID: siteID,
91+
slug: slug ?? status.rawValue,
92+
total: 0)
93+
94+
let storageOrderStatus = storage.insertNewObject(ofType: StorageOrderStatus.self)
95+
storageOrderStatus.update(with: readOnlyOrderStatus)
96+
97+
return readOnlyOrderStatus
98+
}
99+
}

0 commit comments

Comments
 (0)