Skip to content

Commit a35de68

Browse files
feat: wildcard (*) events, internal transitions, Choose conditional actions (#58)
Three more XState-class primitives: - Wildcard '*' event: catch-all transition matched when no exact event handler applies; exact matches keep priority; honors guards and hierarchical child-priority bubbling. - Internal(): transition runs its actions without exiting/re-entering the source state — no entry/exit hooks, no state change, no history. Target optional (build-validated to be empty or the owning state). - Choose(): conditional-action combinator returning an Action[C] that runs the first branch whose guard passes (nil guard = else). Pure helper. Wildcard and internal transitions round-trip through the Native JSON and XState v5 exporters (on["*"], internal: true, targetless = internal). 9 new feature tests + export round-trip tests. Full suite green, lint clean, coverage gates pass.
1 parent 019dc0a commit a35de68

11 files changed

Lines changed: 430 additions & 7 deletions

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Step-by-step migration guides:
6363
- History states (shallow and deep)
6464
- Delayed transitions and parallel/orthogonal regions
6565
- Eventless (`Always`) transitions, `Raise` internal events, and state `Tags`
66+
- Wildcard (`*`) event handlers, internal transitions, and `Choose` conditional actions
6667
- Guards, actions, entry/exit hooks
6768
- Reflection DSL — define machines with struct tags
6869
- Build-time validation
@@ -258,6 +259,37 @@ Key behaviors:
258259
- `HasTag` matches the active leaf, its ancestors, and active parallel-region leaves.
259260
- All three round-trip through the Native JSON and XState v5 exporters (`always`, `tags`, and `xstate.raise` action descriptors).
260261

262+
## Wildcard Events, Internal Transitions & Choose
263+
264+
**Wildcard `*`** catches any event not handled by a specific transition (exact
265+
matches always win; it bubbles like any handler). **`Internal()`** runs a
266+
transition's actions without exiting or re-entering the state — no entry/exit
267+
hooks, no state change — in contrast to an external self-transition. **`Choose`**
268+
is a conditional-action combinator: it runs the first branch whose guard passes.
269+
270+
```go
271+
machine, _ := statekit.NewMachine[Ctx]("ops").
272+
WithInitial("running").
273+
WithAction("audit", statekit.Choose(
274+
statekit.ChooseBranch[Ctx]{When: isAdmin, Then: logAdmin},
275+
statekit.ChooseBranch[Ctx]{Then: logUser}, // else
276+
)).
277+
State("running").
278+
On("TICK").Internal().Do("audit").End(). // no exit/entry, no state change
279+
On("*").Target("unknown").End(). // catch-all fallback
280+
On("STOP").Target("stopped").End(). // exact match beats "*"
281+
Done().
282+
State("unknown").Final().Done().
283+
State("stopped").Final().Done().
284+
Build()
285+
```
286+
287+
Key behaviors:
288+
- Wildcard `*` is lowest priority within a state and honors guards; child handlers still take priority over ancestors.
289+
- Internal transitions accept an empty target (or the owning state); build-time validated.
290+
- `Choose` is a plain `Action[C]` — register it and reference it anywhere an action is used; a branch with a nil `When` is the else.
291+
- Wildcard and internal transitions round-trip through both exporters (`on["*"]`, `internal: true`).
292+
261293
## Reflection DSL
262294

263295
Define machines using struct tags:

builder.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ type TransitionBuilder[C any] struct {
117117

118118
// Raised internal events (v1.x)
119119
raise []EventType
120+
121+
// Internal transition flag — run actions without exit/entry (v1.x)
122+
internal bool
120123
}
121124

122125
// NewMachine creates a new MachineBuilder with the given ID
@@ -251,6 +254,7 @@ func buildStateRecursive[C any](sb *StateBuilder[C], parentID ir.StateID, machin
251254
trans.Actions = append(trans.Actions, tb.actions...)
252255
trans.Delay = tb.delay // Delayed transitions (v2.0)
253256
trans.Raise = append(trans.Raise, tb.raise...)
257+
trans.Internal = tb.internal // Internal transitions (v1.x)
254258
state.Transitions = append(state.Transitions, trans)
255259
}
256260

@@ -673,6 +677,16 @@ func (b *TransitionBuilder[C]) Raise(events ...EventType) *TransitionBuilder[C]
673677
return b
674678
}
675679

680+
// Internal marks the transition as internal (v1.x): its actions run without
681+
// exiting or re-entering the source state — entry/exit hooks do not fire and
682+
// the active state does not change. Target is optional; when set it must be
683+
// the owning state. Contrast with an external self-transition (a plain
684+
// Target back to the same state), which does exit and re-enter.
685+
func (b *TransitionBuilder[C]) Internal() *TransitionBuilder[C] {
686+
b.internal = true
687+
return b
688+
}
689+
676690
// On starts a new transition on the same state (chainable)
677691
func (b *TransitionBuilder[C]) On(event EventType) *TransitionBuilder[C] {
678692
return b.state.On(event)

choose.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package statekit
2+
3+
// ChooseBranch is one arm of a Choose action: when When passes (or is nil,
4+
// acting as an "else"), Then runs and evaluation stops (v1.x).
5+
type ChooseBranch[C any] struct {
6+
// When gates this branch. A nil When always matches — use it as the
7+
// final else branch.
8+
When Guard[C]
9+
// Then is the action executed when this branch is selected. May be nil.
10+
Then Action[C]
11+
}
12+
13+
// Choose builds an Action that runs the Then of the first branch whose When
14+
// guard passes, then stops — the action equivalent of XState's choose(). A
15+
// branch with a nil When acts as an else. If no branch matches, it is a no-op.
16+
//
17+
// Register it like any named action and reference it from transitions or
18+
// entry/exit hooks:
19+
//
20+
// WithAction("classify", statekit.Choose(
21+
// statekit.ChooseBranch[Ctx]{When: isGold, Then: tagGold},
22+
// statekit.ChooseBranch[Ctx]{When: isSilver, Then: tagSilver},
23+
// statekit.ChooseBranch[Ctx]{Then: tagBronze}, // else
24+
// ))
25+
func Choose[C any](branches ...ChooseBranch[C]) Action[C] {
26+
return func(ctx *C, event Event) {
27+
for _, b := range branches {
28+
if b.When == nil || b.When(*ctx, event) {
29+
if b.Then != nil {
30+
b.Then(ctx, event)
31+
}
32+
return
33+
}
34+
}
35+
}
36+
}

choose_wildcard_internal_test.go

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
package statekit
2+
3+
import "testing"
4+
5+
// --- Choose (conditional action combinator) ---
6+
7+
func TestChoose_RunsFirstMatchingBranch(t *testing.T) {
8+
type Ctx struct {
9+
N int
10+
Tier string
11+
}
12+
13+
pick := Choose(
14+
ChooseBranch[Ctx]{
15+
When: func(c Ctx, _ Event) bool { return c.N >= 100 },
16+
Then: func(c *Ctx, _ Event) { c.Tier = "gold" },
17+
},
18+
ChooseBranch[Ctx]{
19+
When: func(c Ctx, _ Event) bool { return c.N >= 10 },
20+
Then: func(c *Ctx, _ Event) { c.Tier = "silver" },
21+
},
22+
ChooseBranch[Ctx]{
23+
// nil When = else branch
24+
Then: func(c *Ctx, _ Event) { c.Tier = "bronze" },
25+
},
26+
)
27+
28+
cases := []struct {
29+
n int
30+
want string
31+
}{
32+
{150, "gold"},
33+
{50, "silver"},
34+
{1, "bronze"},
35+
}
36+
for _, tc := range cases {
37+
c := Ctx{N: tc.n}
38+
pick(&c, Event{})
39+
if c.Tier != tc.want {
40+
t.Errorf("N=%d tier=%q want %q", tc.n, c.Tier, tc.want)
41+
}
42+
}
43+
}
44+
45+
func TestChoose_NoBranchMatchesIsNoop(t *testing.T) {
46+
type Ctx struct{ Hit bool }
47+
pick := Choose(ChooseBranch[Ctx]{
48+
When: func(c Ctx, _ Event) bool { return false },
49+
Then: func(c *Ctx, _ Event) { c.Hit = true },
50+
})
51+
c := Ctx{}
52+
pick(&c, Event{})
53+
if c.Hit {
54+
t.Errorf("expected no branch to run")
55+
}
56+
}
57+
58+
func TestChoose_WiresIntoTransition(t *testing.T) {
59+
type Ctx struct {
60+
Score int
61+
Label string
62+
}
63+
m, err := NewMachine[Ctx]("choose").
64+
WithInitial("idle").
65+
WithContext(Ctx{Score: 42}).
66+
WithAction("classify", Choose(
67+
ChooseBranch[Ctx]{
68+
When: func(c Ctx, _ Event) bool { return c.Score >= 40 },
69+
Then: func(c *Ctx, _ Event) { c.Label = "pass" },
70+
},
71+
ChooseBranch[Ctx]{Then: func(c *Ctx, _ Event) { c.Label = "fail" }},
72+
)).
73+
State("idle").On("GRADE").Target("done").Do("classify").End().Done().
74+
State("done").Final().Done().
75+
Build()
76+
if err != nil {
77+
t.Fatalf("build: %v", err)
78+
}
79+
i := NewInterpreter(m)
80+
i.Start()
81+
i.Send(Event{Type: "GRADE"})
82+
if got := i.State().Context.Label; got != "pass" {
83+
t.Errorf("label = %q, want pass", got)
84+
}
85+
}
86+
87+
// --- Wildcard event ---
88+
89+
func TestWildcard_MatchesUnhandledEvent(t *testing.T) {
90+
m, err := NewMachine[struct{}]("wild").
91+
WithInitial("a").
92+
State("a").
93+
On("KNOWN").Target("known").End().
94+
On("*").Target("fallback").End().
95+
Done().
96+
State("known").Final().Done().
97+
State("fallback").Final().Done().
98+
Build()
99+
if err != nil {
100+
t.Fatalf("build: %v", err)
101+
}
102+
i := NewInterpreter(m)
103+
i.Start()
104+
i.Send(Event{Type: "SOMETHING_ELSE"})
105+
if got := i.State().Value; got != "fallback" {
106+
t.Errorf("state = %q, want fallback", got)
107+
}
108+
}
109+
110+
func TestWildcard_ExactMatchTakesPriority(t *testing.T) {
111+
m, _ := NewMachine[struct{}]("wild2").
112+
WithInitial("a").
113+
State("a").
114+
On("KNOWN").Target("known").End().
115+
On("*").Target("fallback").End().
116+
Done().
117+
State("known").Final().Done().
118+
State("fallback").Final().Done().
119+
Build()
120+
i := NewInterpreter(m)
121+
i.Start()
122+
i.Send(Event{Type: "KNOWN"})
123+
if got := i.State().Value; got != "known" {
124+
t.Errorf("state = %q, want known (exact beats wildcard)", got)
125+
}
126+
}
127+
128+
func TestWildcard_RespectsGuard(t *testing.T) {
129+
m, _ := NewMachine[struct{ Allow bool }]("wild3").
130+
WithInitial("a").
131+
WithGuard("allow", func(c struct{ Allow bool }, _ Event) bool { return c.Allow }).
132+
WithContext(struct{ Allow bool }{Allow: false}).
133+
State("a").On("*").Target("b").Guard("allow").End().Done().
134+
State("b").Final().Done().
135+
Build()
136+
i := NewInterpreter(m)
137+
i.Start()
138+
i.Send(Event{Type: "ANY"})
139+
if got := i.State().Value; got != "a" {
140+
t.Errorf("state = %q, want a (guard blocks wildcard)", got)
141+
}
142+
}
143+
144+
// --- Internal transitions ---
145+
146+
func TestInternal_NoExitEntryOrStateChange(t *testing.T) {
147+
type Ctx struct {
148+
Entries int
149+
Exits int
150+
Pings int
151+
}
152+
m, err := NewMachine[Ctx]("internal").
153+
WithInitial("active").
154+
WithAction("onEntry", func(c *Ctx, _ Event) { c.Entries++ }).
155+
WithAction("onExit", func(c *Ctx, _ Event) { c.Exits++ }).
156+
WithAction("onPing", func(c *Ctx, _ Event) { c.Pings++ }).
157+
State("active").
158+
OnEntry("onEntry").
159+
OnExit("onExit").
160+
On("PING").Internal().Do("onPing").End().
161+
Done().
162+
Build()
163+
if err != nil {
164+
t.Fatalf("build: %v", err)
165+
}
166+
i := NewInterpreter(m)
167+
i.Start() // 1 entry
168+
i.Send(Event{Type: "PING"})
169+
i.Send(Event{Type: "PING"})
170+
171+
st := i.State()
172+
if st.Value != "active" {
173+
t.Errorf("state = %q, want active", st.Value)
174+
}
175+
if st.Context.Pings != 2 {
176+
t.Errorf("pings = %d, want 2", st.Context.Pings)
177+
}
178+
if st.Context.Entries != 1 {
179+
t.Errorf("entries = %d, want 1 (internal must not re-enter)", st.Context.Entries)
180+
}
181+
if st.Context.Exits != 0 {
182+
t.Errorf("exits = %d, want 0 (internal must not exit)", st.Context.Exits)
183+
}
184+
}
185+
186+
func TestInternal_ContrastsWithExternalSelfTransition(t *testing.T) {
187+
type Ctx struct{ Entries, Exits int }
188+
m, _ := NewMachine[Ctx]("ext").
189+
WithInitial("active").
190+
WithAction("onEntry", func(c *Ctx, _ Event) { c.Entries++ }).
191+
WithAction("onExit", func(c *Ctx, _ Event) { c.Exits++ }).
192+
State("active").
193+
OnEntry("onEntry").
194+
OnExit("onExit").
195+
On("SELF").Target("active").End(). // external self-transition (re-enters)
196+
Done().
197+
Build()
198+
i := NewInterpreter(m)
199+
i.Start()
200+
i.Send(Event{Type: "SELF"})
201+
st := i.State()
202+
if st.Context.Exits != 1 || st.Context.Entries != 2 {
203+
t.Errorf("external self: entries=%d exits=%d, want entries=2 exits=1", st.Context.Entries, st.Context.Exits)
204+
}
205+
}
206+
207+
func TestInternal_AllowsEmptyTarget(t *testing.T) {
208+
type Ctx struct{ Count int }
209+
m, err := NewMachine[Ctx]("internal2").
210+
WithInitial("s").
211+
WithAction("bump", func(c *Ctx, _ Event) { c.Count++ }).
212+
State("s").On("BUMP").Internal().Do("bump").End().Done().
213+
Build()
214+
if err != nil {
215+
t.Fatalf("build: %v", err)
216+
}
217+
i := NewInterpreter(m)
218+
i.Start()
219+
i.Send(Event{Type: "BUMP"})
220+
if i.State().Context.Count != 1 {
221+
t.Errorf("count = %d, want 1", i.State().Context.Count)
222+
}
223+
if i.State().Value != "s" {
224+
t.Errorf("state = %q, want s", i.State().Value)
225+
}
226+
}

0 commit comments

Comments
 (0)