[READ-ONLY] Mirror of https://github.com/andrioid/statesman. xstate inspired schema compatible statechart library for Go
fsm
go
statecharts
1package statesman_test
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/andrioid/statesman"
9 "github.com/andrioid/statesman/statesmantest"
10)
11
12// TestAdaptersOrderRetryWithFakeActor drives the full §11 retry loop with real
13// invoke spawning: charge is a FakeActor scripting fail-then-succeed, inventory a
14// fake callback recording the SendTo from validateForm.
15func TestAdaptersOrderRetryWithFakeActor(t *testing.T) {
16 ctx := context.Background()
17 rec := &statesmantest.CommandRecorder[oEvt]{}
18 s := newOrderSync(t)
19 s.M.RegisterInvoke("charge", statesmantest.FakeActor[oCtx, oEvt](
20 oEvt{"error.invoke.charge"}, // attempt 1 fails
21 oEvt{"done.invoke.charge"}, // attempt 2 succeeds
22 ))
23 s.M.RegisterInvoke("inventory", statesmantest.FakeCallback[oCtx, oEvt, oEvt](rec))
24
25 if err := s.Start(ctx, "order-1"); err != nil {
26 t.Fatalf("start: %v", err)
27 }
28 if !hasState(s.Snapshot(), "idle") {
29 t.Fatalf("initial = %v, want idle", s.Snapshot().ActiveStates)
30 }
31
32 // SUBMIT enters charging, spawns charge (attempt 1 fails synchronously), and
33 // the guarded onError edge lands in retrying with Retries incremented.
34 mustSend(t, s, "SUBMIT")
35 if !hasState(s.Snapshot(), "retrying") {
36 t.Fatalf("after SUBMIT+fail = %v, want retrying", s.Snapshot().ActiveStates)
37 }
38 if s.Snapshot().Context.Retries != 1 {
39 t.Fatalf("Retries = %d, want 1", s.Snapshot().Context.Retries)
40 }
41 // validateForm forwarded the SKUs to the inventory callback.
42 cmds := rec.Commands()
43 if len(cmds) != 1 || cmds[0].EventType() != "WATCH_SKUS" {
44 t.Fatalf("inventory commands = %v, want one WATCH_SKUS", cmds)
45 }
46
47 // Backoff elapses: re-enter charging, attempt 2 succeeds -> confirming.
48 if err := s.Advance(ctx, 5*time.Second); err != nil {
49 t.Fatalf("advance: %v", err)
50 }
51 if !hasState(s.Snapshot(), "confirming") {
52 t.Fatalf("after backoff+success = %v, want confirming", s.Snapshot().ActiveStates)
53 }
54
55 mustSend(t, s, "CONFIRM")
56 if snap := s.Snapshot(); snap.Status != statesman.StatusDone || !hasState(snap, "done") {
57 t.Fatalf("final = %v/%v, want done/Done", snap.ActiveStates, snap.Status)
58 }
59 _ = s.M.Close()
60}
61
62// TestAdaptersRealPromise exercises the real PromiseActor goroutine path (async
63// completion bridged to done.invoke.charge), on the wall clock.
64func TestAdaptersRealPromise(t *testing.T) {
65 ctx := context.Background()
66 def := loadOrder(t)
67 actType, guardType := callsiteMaps(def)
68 apply, guard := orderAppliers(actType, guardType)
69 m := statesman.NewMachine[oCtx, oEvt](def, oCtx{}, apply, guard)
70
71 type chargeIn struct{ amount int }
72 type chargeOut struct{ id string }
73 m.RegisterInvoke("charge", statesman.PromiseActor[oCtx, oEvt, chargeIn, chargeOut](
74 func(_ context.Context, _ chargeIn) (chargeOut, error) { return chargeOut{id: "ch_1"}, nil },
75 func(oCtx) chargeIn { return chargeIn{amount: 100} },
76 func(chargeOut) oEvt { return oEvt{"done.invoke.charge"} },
77 func(error) oEvt { return oEvt{"error.invoke.charge"} },
78 ))
79 // inventory: a no-op real callback that blocks until cancelled.
80 m.RegisterInvoke("inventory", statesman.CallbackActor[oCtx, oEvt, oEvt](
81 func(ctx context.Context, _ func(oEvt), _ <-chan oEvt) error { <-ctx.Done(); return nil },
82 nil, nil,
83 ))
84
85 if err := m.Start(ctx, "order-real"); err != nil {
86 t.Fatalf("start: %v", err)
87 }
88 if err := m.Send(ctx, oEvt{"SUBMIT"}); err != nil {
89 t.Fatalf("send: %v", err)
90 }
91 // The promise resolves asynchronously; wait for the bridged done event to
92 // drive charging -> confirming.
93 waitForState(t, m, "confirming", 2*time.Second)
94 _ = m.Close()
95}
96
97func waitForState(t *testing.T, m *statesman.Machine[oCtx, oEvt], id statesman.StateID, timeout time.Duration) {
98 t.Helper()
99 deadline := time.Now().Add(timeout)
100 for time.Now().Before(deadline) {
101 if hasState(m.Snapshot(), id) {
102 return
103 }
104 time.Sleep(2 * time.Millisecond)
105 }
106 t.Fatalf("state %q not reached within %v; active = %v", id, timeout, m.Snapshot().ActiveStates)
107}
108
109// TestInvokeRestartCountSurfaced drives one fail->retry->re-invoke of "charge"
110// and asserts the snapshot reports the restart (spawns beyond the first), while a
111// once-spawned invoke ("inventory") never appears.
112func TestInvokeRestartCountSurfaced(t *testing.T) {
113 ctx := context.Background()
114 rec := &statesmantest.CommandRecorder[oEvt]{}
115 s := newOrderSync(t)
116 s.M.RegisterInvoke("charge", statesmantest.FakeActor[oCtx, oEvt](
117 oEvt{"error.invoke.charge"}, // attempt 1 fails
118 oEvt{"done.invoke.charge"}, // attempt 2 (after re-invoke) succeeds
119 ))
120 s.M.RegisterInvoke("inventory", statesmantest.FakeCallback[oCtx, oEvt, oEvt](rec))
121 if err := s.Start(ctx, "order-1"); err != nil {
122 t.Fatalf("start: %v", err)
123 }
124
125 // First spawn of charge: not a restart yet.
126 mustSend(t, s, "SUBMIT")
127 if got := s.Snapshot().InvokeRestarts["charge"]; got != 0 {
128 t.Fatalf("after first spawn, charge restarts = %d, want 0", got)
129 }
130
131 // Backoff elapses -> re-enter charging -> charge re-invoked (a restart),
132 // attempt 2 succeeds -> confirming. The restart is visible by then.
133 if err := s.Advance(ctx, 5*time.Second); err != nil {
134 t.Fatalf("advance: %v", err)
135 }
136 snap := s.Snapshot()
137 if got := snap.InvokeRestarts["charge"]; got != 1 {
138 t.Fatalf("charge restarts = %d, want 1 (active=%v)", got, snap.ActiveStates)
139 }
140 if _, ok := snap.InvokeRestarts["inventory"]; ok {
141 t.Fatalf("inventory should not appear in restarts: %v", snap.InvokeRestarts)
142 }
143 _ = s.M.Close()
144}