-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathHydraTest.java
More file actions
236 lines (190 loc) · 9.43 KB
/
Copy pathHydraTest.java
File metadata and controls
236 lines (190 loc) · 9.43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
/***************************************************************************************************
* Copyright (c) 2023, Salesforce, Inc. All rights reserved. SPDX-License-Identifier:
* Apache License Version 2.0
* For full license text, see the LICENSE file in the repo root or
* http://www.apache.org/licenses/LICENSE-2.0
**************************************************************************************************/
package com.salesforce.hydra;
import static com.google.common.truth.Truth.assertThat;
import com.salesforce.hydra.OrderDomain.Cancel;
import com.salesforce.hydra.OrderDomain.Cancelled;
import com.salesforce.hydra.OrderDomain.Cart;
import com.salesforce.hydra.OrderDomain.ChargeCard;
import com.salesforce.hydra.OrderDomain.Checkout;
import com.salesforce.hydra.OrderDomain.OrderAction;
import com.salesforce.hydra.OrderDomain.OrderEvent;
import com.salesforce.hydra.OrderDomain.OrderState;
import com.salesforce.hydra.OrderDomain.PaymentFailed;
import com.salesforce.hydra.OrderDomain.PaymentSucceeded;
import com.salesforce.hydra.OrderDomain.Placed;
import com.salesforce.hydra.OrderDomain.RefundCard;
import com.salesforce.hydra.OrderDomain.ShipParcel;
import com.salesforce.hydra.OrderDomain.Shipped;
import com.salesforce.hydra.statemachine.Transition;
import java.util.function.BiConsumer;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
class HydraTest {
private static final long AMOUNT = 4_999L;
private static final String ADDRESS = "1 Market St, San Francisco";
Hydra<OrderState, OrderEvent, OrderAction> orderMachineStartingAt(OrderState initialState) {
return orderMachine.cloneWith(mb -> mb.initialState(initialState));
}
@Test
void testInitialStateIsCart() {
assertThat(orderMachine.getState()).isEqualTo(new Cart());
}
@Test
@DisplayName("Checkout (event w/ data) -> Placed, emits ChargeCard (action w/ data)")
void testCheckoutPlacesOrderAndEmitsCharge() {
final var machine = orderMachineStartingAt(new Cart());
final var transition = machine.transition(new Checkout(AMOUNT, ADDRESS));
assertThat(transition.isValid()).isTrue();
assertThat(machine.getState()).isEqualTo(new Placed(AMOUNT, ADDRESS));
assertThat(actionOf(transition)).isEqualTo(new ChargeCard(AMOUNT));
}
@Test
@DisplayName(
"Mealy star edge: bare event PaymentSucceeded -> ShipParcel action sourced from STATE")
void testPaymentSucceededShipsUsingAddressFromState() {
// The event carries NO data; the emitted action's address comes from the Placed STATE.
// This is why Event and Action are different types: input signal vs. data-carrying output.
final var machine = orderMachineStartingAt(new Placed(AMOUNT, ADDRESS));
final var transition = machine.transition(new PaymentSucceeded());
assertThat(transition.isValid()).isTrue();
assertThat(machine.getState()).isEqualTo(new Shipped(AMOUNT, ADDRESS));
assertThat(actionOf(transition)).isEqualTo(new ShipParcel(ADDRESS));
}
@Test
@DisplayName("PaymentFailed -> Cancelled with NO action (action payload is optional)")
void testPaymentFailedCancelsWithoutAction() {
final var machine = orderMachineStartingAt(new Placed(AMOUNT, ADDRESS));
final var transition = machine.transition(new PaymentFailed("card declined"));
assertThat(transition.isValid()).isTrue();
assertThat(machine.getState()).isEqualTo(new Cancelled());
assertThat(actionOf(transition)).isNull();
}
@Test
@DisplayName("Cancel a Shipped order -> Cancelled, emits RefundCard sourced from STATE")
void testCancelShippedOrderRefunds() {
final var machine = orderMachineStartingAt(new Shipped(AMOUNT, ADDRESS));
final var transition = machine.transition(new Cancel());
assertThat(transition.isValid()).isTrue();
assertThat(machine.getState()).isEqualTo(new Cancelled());
assertThat(actionOf(transition)).isEqualTo(new RefundCard(AMOUNT));
}
@Test
@DisplayName("Invalid transition: Cancel while in Cart - rejected, state unchanged")
void testCancelInCartIsInvalid() {
final var machine = orderMachineStartingAt(new Cart());
final var transition = machine.transition(new Cancel());
assertThat(transition.isValid()).isFalse();
assertThat(transition).isInstanceOf(Transition.Invalid.class);
assertThat(machine.getState()).isEqualTo(new Cart());
}
@Test
@DisplayName("Read-only transition computes the move and fires listeners without mutating state")
void testReadTransitionDoesNotMutateState() {
final var machine = orderMachineStartingAt(new Placed(AMOUNT, ADDRESS));
final var transition =
machine.readTransitionAndNotifyListeners(
Placed.class, new Placed(AMOUNT, ADDRESS), new PaymentSucceeded());
assertThat(transition.isValid()).isTrue();
assertThat(actionOf(transition)).isEqualTo(new ShipParcel(ADDRESS));
// State is untouched — durable truth lives outside the in-memory machine.
assertThat(machine.getState()).isEqualTo(new Placed(AMOUNT, ADDRESS));
Mockito.verify(onShippedEnter).accept(new Shipped(AMOUNT, ADDRESS), new PaymentSucceeded());
}
@Test
@DisplayName("2-arg read-only transition derives the class from the instance")
void testReadTransitionTwoArgOverload() {
final var machine = orderMachineStartingAt(new Placed(AMOUNT, ADDRESS));
final var transition =
machine.readTransitionAndNotifyListeners(
new Placed(AMOUNT, ADDRESS), new PaymentSucceeded());
assertThat(transition.isValid()).isTrue();
assertThat(actionOf(transition)).isEqualTo(new ShipParcel(ADDRESS));
assertThat(machine.getState()).isEqualTo(new Placed(AMOUNT, ADDRESS));
}
@Test
@DisplayName("onExit / onEnter listeners fire on a valid transition")
void testEnterAndExitListenersFire() {
final var machine = orderMachineStartingAt(new Placed(AMOUNT, ADDRESS));
machine.transition(new PaymentSucceeded());
Mockito.verify(onPlacedExit).accept(new Placed(AMOUNT, ADDRESS), new PaymentSucceeded());
Mockito.verify(onShippedEnter).accept(new Shipped(AMOUNT, ADDRESS), new PaymentSucceeded());
}
@Test
@DisplayName("Handling an action: switch over the emitted command and perform the side effect")
void testDispatchActionPerformsSideEffect() {
final var machine = orderMachineStartingAt(new Cart());
// 1. Drive the machine. Hydra returns the action — it does NOT perform it.
final var transition = machine.transition(new Checkout(AMOUNT, ADDRESS));
final OrderAction action = actionOf(transition);
// 2. WE interpret the action and perform the effect (exhaustive over the sealed type).
perform(action, effects);
// 3. The effect happened because we dispatched it — Hydra only handed us the command.
Mockito.verify(effects).chargeCard(AMOUNT);
Mockito.verifyNoMoreInteractions(effects);
}
/** A sink for the real-world side effects an {@link OrderAction} commands. */
interface Effects {
void chargeCard(long amountCents);
void shipParcel(String address);
void refundCard(long amountCents);
}
/** Interpret a command and perform it. Exhaustive switch — the compiler enforces coverage. */
private static void perform(OrderAction action, Effects effects) {
switch (action) {
case ChargeCard c -> effects.chargeCard(c.amountCents());
case ShipParcel s -> effects.shipParcel(s.address());
case RefundCard r -> effects.refundCard(r.amountCents());
}
}
@SuppressWarnings("unchecked")
private static OrderAction actionOf(Transition<OrderState, OrderEvent, OrderAction> transition) {
return ((Transition.Valid<OrderState, OrderEvent, OrderAction>) transition).getAction();
}
final Effects effects = Mockito.mock();
final BiConsumer<Placed, OrderEvent> onPlacedExit = Mockito.mock();
final BiConsumer<Shipped, OrderEvent> onShippedEnter = Mockito.mock();
public final Hydra<OrderState, OrderEvent, OrderAction> orderMachine =
Hydra.create(
mb -> {
mb.initialState(new Cart());
mb.state(
Cart.class,
sb ->
sb.on(
Checkout.class,
(cart, checkout) ->
sb.transitionTo(
new Placed(checkout.amountCents(), checkout.address()),
new ChargeCard(checkout.amountCents()))));
mb.state(
Placed.class,
sb -> {
sb.onExit(onPlacedExit);
// Star edge: bare event in, data-carrying action out — address read from STATE.
sb.on(
PaymentSucceeded.class,
(placed, event) ->
sb.transitionTo(
new Shipped(placed.amountCents(), placed.address()),
new ShipParcel(placed.address())));
// No-action transition — the action payload is optional.
sb.on(PaymentFailed.class, (placed, event) -> sb.transitionTo(new Cancelled()));
});
mb.state(
Shipped.class,
sb -> {
sb.onEnter(onShippedEnter);
sb.on(
Cancel.class,
(shipped, event) ->
sb.transitionTo(new Cancelled(), new RefundCard(shipped.amountCents())));
});
mb.state(Cancelled.class, sb -> {});
});
}