Skip to content

Commit 660b4dd

Browse files
authored
[Woo POS] Use Observation for order state (#15087)
2 parents 5e8e773 + 236fe04 commit 660b4dd

File tree

4 files changed

+87
-77
lines changed

4 files changed

+87
-77
lines changed

WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import Foundation
2-
import Combine
2+
import Observation
33
import protocol Yosemite.StoresManager
44
import protocol Yosemite.POSOrderServiceProtocol
55
import protocol Yosemite.POSReceiptServiceProtocol
@@ -14,15 +14,16 @@ import enum WooFoundation.CurrencyCode
1414
import protocol WooFoundation.Analytics
1515

1616
protocol PointOfSaleOrderControllerProtocol {
17-
var orderStatePublisher: AnyPublisher<PointOfSaleInternalOrderState, Never> { get }
17+
var orderState: PointOfSaleInternalOrderState { get }
1818

1919
func syncOrder(for cartProducts: [CartItem], retryHandler: @escaping () async -> Void) async
2020
func sendReceipt(recipientEmail: String) async throws
2121
func clearOrder()
2222
func collectCashPayment() async throws
2323
}
2424

25-
final class PointOfSaleOrderController: PointOfSaleOrderControllerProtocol {
25+
@available(iOS 17.0, *)
26+
@Observable final class PointOfSaleOrderController: PointOfSaleOrderControllerProtocol {
2627
init(orderService: POSOrderServiceProtocol,
2728
receiptService: POSReceiptServiceProtocol,
2829
stores: StoresManager = ServiceLocator.stores,
@@ -38,10 +39,6 @@ final class PointOfSaleOrderController: PointOfSaleOrderControllerProtocol {
3839
self.celebration = celebration
3940
}
4041

41-
var orderStatePublisher: AnyPublisher<PointOfSaleInternalOrderState, Never> {
42-
$orderState.eraseToAnyPublisher()
43-
}
44-
4542
private let orderService: POSOrderServiceProtocol
4643
private let receiptService: POSReceiptServiceProtocol
4744

@@ -51,7 +48,7 @@ final class PointOfSaleOrderController: PointOfSaleOrderControllerProtocol {
5148
private let analytics: Analytics
5249
private let stores: StoresManager
5350

54-
@Published private var orderState: PointOfSaleInternalOrderState = .idle
51+
private(set) var orderState: PointOfSaleInternalOrderState = .idle
5552
private var order: Order? = nil
5653

5754
@MainActor
@@ -151,7 +148,7 @@ final class PointOfSaleOrderController: PointOfSaleOrderControllerProtocol {
151148
}
152149
}
153150

154-
151+
@available(iOS 17.0, *)
155152
private extension PointOfSaleOrderController {
156153
func totals(for order: Order) -> PointOfSaleOrderTotals {
157154
let totalsCalculator = OrderTotalsCalculator(for: order,
@@ -222,6 +219,7 @@ extension PointOfSaleInternalOrderState: Equatable {
222219
}
223220
}
224221

222+
@available(iOS 17.0, *)
225223
extension PointOfSaleOrderController {
226224
enum Localization {
227225
static let cashPaymentMethodTitle = NSLocalizedString(

WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ protocol PointOfSaleAggregateModelProtocol {
5454

5555
private(set) var cart: [CartItem] = []
5656

57-
private(set) var orderState: PointOfSaleOrderState = .idle
58-
private var internalOrderState: PointOfSaleInternalOrderState = .idle
57+
var orderState: PointOfSaleOrderState { orderController.orderState.externalState }
58+
private var internalOrderState: PointOfSaleInternalOrderState { orderController.orderState }
5959

6060
private let itemsController: PointOfSaleItemsControllerProtocol
6161

@@ -80,7 +80,6 @@ protocol PointOfSaleAggregateModelProtocol {
8080
self.paymentState = paymentState
8181
publishCardReaderConnectionStatus()
8282
publishPaymentMessages()
83-
publishOrderState()
8483
setupReaderReconnectionObservation()
8584
}
8685
}
@@ -429,21 +428,6 @@ extension PointOfSaleAggregateModel {
429428
})
430429
await startPaymentWhenCardReaderConnected()
431430
}
432-
433-
func publishOrderState() {
434-
orderController.orderStatePublisher
435-
.map { $0.externalState }
436-
.sink(receiveValue: { [weak self] orderState in
437-
self?.orderState = orderState
438-
})
439-
.store(in: &cancellables)
440-
441-
orderController.orderStatePublisher
442-
.sink(receiveValue: { [weak self] internalOrderState in
443-
self?.internalOrderState = internalOrderState
444-
})
445-
.store(in: &cancellables)
446-
}
447431
}
448432

449433
@available(iOS 17.0, *)

WooCommerce/Classes/POS/Utils/PointOfSalePreviewOrderController.swift

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,13 @@ import struct Yosemite.Order
44
import Combine
55

66
class PointOfSalePreviewOrderController: PointOfSaleOrderControllerProtocol {
7-
var orderStatePublisher: AnyPublisher<PointOfSaleInternalOrderState, Never> = Just(
8-
.loaded(
9-
.init(cartTotal: "$10.50",
10-
orderTotal: "$12.00",
11-
taxTotal: "$1.50",
12-
orderTotalDecimal: 12.00),
13-
OrderFactory.newOrder(currency: .USD)
14-
)
15-
).eraseToAnyPublisher()
7+
var orderState: PointOfSaleInternalOrderState = .loaded(
8+
.init(cartTotal: "$10.50",
9+
orderTotal: "$12.00",
10+
taxTotal: "$1.50",
11+
orderTotalDecimal: 12.00),
12+
OrderFactory.newOrder(currency: .USD)
13+
)
1614

1715
func syncOrder(for cartProducts: [CartItem],
1816
retryHandler: @escaping () async -> Void) async { }

WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift

Lines changed: 71 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import Testing
2-
import Combine
2+
import Observation
33
import Foundation
44

55
@testable import WooCommerce
@@ -10,17 +10,14 @@ import class WooFoundation.CurrencySettings
1010
import protocol WooFoundation.Analytics
1111

1212
struct PointOfSaleOrderControllerTests {
13-
let sut: PointOfSaleOrderController
1413
let mockOrderService = MockPOSOrderService()
1514
let mockReceiptService = MockReceiptService()
1615

17-
init() {
18-
self.sut = PointOfSaleOrderController(orderService: mockOrderService,
19-
receiptService: mockReceiptService)
20-
}
21-
16+
@available(iOS 17.0, *)
2217
@Test func syncOrder_without_items_doesnt_call_orderService() async throws {
2318
// Given
19+
let sut = PointOfSaleOrderController(orderService: mockOrderService,
20+
receiptService: mockReceiptService)
2421

2522
// When
2623
await sut.syncOrder(for: [], retryHandler: {})
@@ -29,8 +26,11 @@ struct PointOfSaleOrderControllerTests {
2926
#expect(mockOrderService.syncOrderWasCalled == false)
3027
}
3128

29+
@available(iOS 17.0, *)
3230
@Test func syncOrder_with_cart_matching_order_doesnt_call_orderService() async throws {
3331
// Given
32+
let sut = PointOfSaleOrderController(orderService: mockOrderService,
33+
receiptService: mockReceiptService)
3434
let orderItem = OrderItem.fake().copy(quantity: 1)
3535
let fakeOrder = Order.fake().copy(items: [orderItem])
3636
let cartItem = makeItem(orderItemsToMatch: [orderItem])
@@ -46,8 +46,11 @@ struct PointOfSaleOrderControllerTests {
4646
#expect(mockOrderService.syncOrderWasCalled == false)
4747
}
4848

49+
@available(iOS 17.0, *)
4950
@Test func syncOrder_when_already_syncing_doesnt_call_orderService() async throws {
5051
// Given
52+
let sut = PointOfSaleOrderController(orderService: mockOrderService,
53+
receiptService: mockReceiptService)
5154
mockOrderService.simulateSyncing = true
5255
Task {
5356
await sut.syncOrder(for: [makeItem(quantity: 1)], retryHandler: {})
@@ -64,6 +67,7 @@ struct PointOfSaleOrderControllerTests {
6467
#expect(mockOrderService.syncOrderWasCalled == false)
6568
}
6669

70+
@available(iOS 17.0, *)
6771
@Test func syncOrder_with_no_previous_order_calls_orderService() async throws {
6872
// Given
6973
let currencySettings = CurrencySettings(currencyCode: .AUD,
@@ -82,8 +86,11 @@ struct PointOfSaleOrderControllerTests {
8286
#expect(mockOrderService.spySyncOrderCurrency == .AUD)
8387
}
8488

89+
@available(iOS 17.0, *)
8590
@Test func syncOrder_with_changes_from_previous_order_calls_orderService() async throws {
8691
// Given
92+
let sut = PointOfSaleOrderController(orderService: mockOrderService,
93+
receiptService: mockReceiptService)
8794
let cartItem = makeItem(quantity: 1)
8895
let orderItem = OrderItem.fake().copy(quantity: 1)
8996
let fakeOrder = Order.fake().copy(items: [orderItem])
@@ -100,25 +107,35 @@ struct PointOfSaleOrderControllerTests {
100107
#expect(mockOrderService.syncOrderWasCalled)
101108
}
102109

110+
@available(iOS 17.0, *)
103111
@Test func syncOrder_with_no_previous_order_sets_orderState_syncing_then_loaded() async throws {
104112
// Given
113+
let sut = PointOfSaleOrderController(orderService: mockOrderService,
114+
receiptService: mockReceiptService)
105115
let fakeOrder = Order.fake()
106116
mockOrderService.orderToReturn = fakeOrder
107-
var cancellables = Set<AnyCancellable>()
108-
var orderStates: [PointOfSaleInternalOrderState] = []
109-
await confirmation() { confirmation in
110-
// We can use `withObservationTracking` when we move to @Observable
111-
sut.orderStatePublisher.collect(3)
112-
.sink { orderState in
113-
orderStates.append(contentsOf: orderState)
117+
var orderStates: [PointOfSaleInternalOrderState] = [sut.orderState]
118+
var orderStateAppendTask: Task<Void, Never>? = nil
119+
await confirmation(expectedCount: 2) { confirmation in
120+
@Sendable func observeOrderState() {
121+
withObservationTracking {
122+
_ = sut.orderState
123+
} onChange: {
124+
orderStateAppendTask = Task { @MainActor in
125+
orderStates.append(sut.orderState)
126+
}
114127
confirmation()
128+
observeOrderState()
115129
}
116-
.store(in: &cancellables)
130+
}
131+
observeOrderState()
117132

118133
// When
119134
await sut.syncOrder(for: [makeItem()], retryHandler: {})
120135
}
121136

137+
await orderStateAppendTask?.value
138+
122139
// Then
123140
#expect(orderStates == [
124141
.idle,
@@ -128,25 +145,35 @@ struct PointOfSaleOrderControllerTests {
128145
])
129146
}
130147

148+
@available(iOS 17.0, *)
131149
@Test func syncOrder_with_order_sync_failure_sets_orderState_syncing_then_error() async throws {
132150
// Given
151+
let sut = PointOfSaleOrderController(orderService: mockOrderService,
152+
receiptService: mockReceiptService)
133153
mockOrderService.orderToReturn = nil
134154

135-
var cancellables = Set<AnyCancellable>()
136-
var orderStates: [PointOfSaleInternalOrderState] = []
137-
await confirmation() { confirmation in
138-
// We can use `withObservationTracking` when we move to @Observable
139-
sut.orderStatePublisher.collect(3)
140-
.sink { orderState in
141-
orderStates.append(contentsOf: orderState)
155+
var orderStates: [PointOfSaleInternalOrderState] = [sut.orderState]
156+
var orderStateAppendTask: Task<Void, Never>? = nil
157+
await confirmation(expectedCount: 2) { confirmation in
158+
@Sendable func observeOrderState() {
159+
withObservationTracking {
160+
_ = sut.orderState
161+
} onChange: {
162+
orderStateAppendTask = Task { @MainActor in
163+
orderStates.append(sut.orderState)
164+
}
142165
confirmation()
166+
observeOrderState()
143167
}
144-
.store(in: &cancellables)
168+
}
169+
observeOrderState()
145170

146171
// When
147172
await sut.syncOrder(for: [makeItem()], retryHandler: {})
148173
}
149174

175+
await orderStateAppendTask?.value
176+
150177
// Then
151178
#expect(orderStates == [
152179
.idle,
@@ -157,8 +184,11 @@ struct PointOfSaleOrderControllerTests {
157184
])
158185
}
159186

187+
@available(iOS 17.0, *)
160188
@Test func sendReceipt_when_there_is_no_order_then_will_not_trigger() async throws {
161189
// Given
190+
let sut = PointOfSaleOrderController(orderService: mockOrderService,
191+
receiptService: mockReceiptService)
162192
let email = "[email protected]"
163193

164194
// When
@@ -169,8 +199,11 @@ struct PointOfSaleOrderControllerTests {
169199
#expect(!mockReceiptService.sendReceiptWasCalled)
170200
}
171201

202+
@available(iOS 17.0, *)
172203
@Test func sendReceipt_calls_both_updateOrder_and_sendReceipt() async throws {
173204
// Given
205+
let sut = PointOfSaleOrderController(orderService: mockOrderService,
206+
receiptService: mockReceiptService)
174207
let order = Order.fake()
175208
let recipientEmail = "[email protected]"
176209
mockOrderService.orderToReturn = order
@@ -187,9 +220,12 @@ struct PointOfSaleOrderControllerTests {
187220
#expect(mockReceiptService.sendReceiptWasCalled)
188221
}
189222

223+
@available(iOS 17.0, *)
190224
@Test func collectCashPayment_when_no_order_then_fails_with_noOrder_error() async throws {
191225
do {
192226
// Given/When
227+
let sut = PointOfSaleOrderController(orderService: mockOrderService,
228+
receiptService: mockReceiptService)
193229
try await sut.collectCashPayment()
194230
} catch let error as PointOfSaleOrderController.PointOfSaleOrderControllerError {
195231
// Then
@@ -198,6 +234,7 @@ struct PointOfSaleOrderControllerTests {
198234
}
199235

200236
@MainActor
237+
@available(iOS 17.0, *)
201238
@Test func collectCashPayment_when_successful_calls_celebrate() async throws {
202239
// Given
203240
let sampleSiteID: Int64 = 1234
@@ -218,7 +255,7 @@ struct PointOfSaleOrderControllerTests {
218255
let completionResult: Bool = await withCheckedContinuation { continuation in
219256
mockStores.whenReceivingAction(ofType: OrderAction.self) { action in
220257
switch action {
221-
case let .updateOrder(siteID, order, _, _, onCompletion):
258+
case let .updateOrder(_, order, _, _, onCompletion):
222259
onCompletion(.success(order))
223260
continuation.resume(returning: true)
224261
default:
@@ -244,17 +281,17 @@ struct PointOfSaleOrderControllerTests {
244281
private let analyticsProvider = MockAnalyticsProvider()
245282
private let orderService = MockPOSOrderService()
246283
private let receiptService = MockReceiptService()
247-
private let sut: PointOfSaleOrderController
248284

249285
init() {
250286
analytics = WooAnalytics(analyticsProvider: analyticsProvider)
251-
sut = PointOfSaleOrderController(orderService: orderService,
252-
receiptService: receiptService,
253-
analytics: analytics)
254287
}
255288

289+
@available(iOS 17.0, *)
256290
@Test func syncOrder_when_create_order_then_tracks_order_creation_success_event() async throws {
257291
// Given
292+
let sut = PointOfSaleOrderController(orderService: orderService,
293+
receiptService: receiptService,
294+
analytics: analytics)
258295
let fakeOrderItem = OrderItem.fake().copy(quantity: 1)
259296
let fakeOrder = Order.fake()
260297
let fakeCartItem = makeItem(orderItemsToMatch: [fakeOrderItem])
@@ -267,23 +304,16 @@ struct PointOfSaleOrderControllerTests {
267304
#expect(analyticsProvider.receivedEvents.first(where: { $0 == "order_creation_success" }) != nil)
268305
}
269306

307+
@available(iOS 17.0, *)
270308
@Test func syncOrder_when_create_order_fails_with_order_service_error_then_tracks_order_creation_failure_event() async throws {
271309
// Given
272-
// 3 states are expected to be confirmed before returning orderState: .idle, .syncing. .error
273-
let confirmationOrderStates = 3
274-
var cancellables = Set<AnyCancellable>()
310+
let sut = PointOfSaleOrderController(orderService: orderService,
311+
receiptService: receiptService,
312+
analytics: analytics)
275313
orderService.orderToReturn = nil
276314

277-
await confirmation() { confirmation in
278-
sut.orderStatePublisher.collect(confirmationOrderStates)
279-
.sink { orderState in
280-
confirmation()
281-
}
282-
.store(in: &cancellables)
283-
284-
// When
285-
await sut.syncOrder(for: [makeItem()], retryHandler: {})
286-
}
315+
// When
316+
await sut.syncOrder(for: [makeItem()], retryHandler: {})
287317

288318
// Then
289319
#expect(analyticsProvider.receivedEvents.first(where: { $0 == "order_creation_failed" }) != nil)

0 commit comments

Comments
 (0)