[READ-ONLY] Mirror of https://github.com/andrioid/statesman. xstate inspired schema compatible statechart library for Go
fsm go statecharts
0

Configure Feed

Select the types of activity you want to include in your feed.

statesman / adapters_test.go
5.4 kB 144 lines
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}