Skip to content

Commit ae68698

Browse files
committed
test: pin enclosing-compound done-transition on parallel completion
Add regression pins for the done-TRANSITION sub-case adjacent to the enclosing-compound OnDone cascade fixed in 94f3302. When a parallel completes inside a compound, a guarded eventless (Always) done-transition declared on the enclosing compound must be taken in the same macrostep, alongside the parallel's and the compound's OnDone settling. The kernel models compound completion as an OnDone action cascade with no implicit done-event; selectEventless already scans every active config leaf and bubbles up its ancestor spine, so the enclosing compound's guarded Always is reachable once the parallel completes. The sub-case was already correct; these tests lock it. Pins: - TakenOnParallelComplete: done-transition fires to next once both regions reach final, with par.OnDone and outer.OnDone each once. - NotTakenWhileIncomplete: control — with one region still active the guard stays false, the done-transition is not taken, and neither OnDone fires. Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent 94f3302 commit ae68698

1 file changed

Lines changed: 187 additions & 0 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package state_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stablekernel/crucible/state"
8+
)
9+
10+
// This file pins the done-TRANSITION sub-case adjacent to commit 94f3302, which
11+
// fixed the enclosing-compound OnDone ACTION cascade (settleEnclosingDone in
12+
// parallel.go) when a parallel completes inside a compound. Here we verify the
13+
// companion: a done-TRANSITION declared on the ENCLOSING COMPOUND is taken when
14+
// the parallel inside it completes, in the SAME macrostep, and exactly as it is
15+
// for the already-working nested-compound case.
16+
//
17+
// crucible/state has no SCXML-style done.state event or implicit done-transition.
18+
// A compound's "done" is settled as an OnDone ACTION cascade (settleParallelDone
19+
// -> settleEnclosingDone), and a state change ON completion is expressed
20+
// idiomatically as an eventless (Always) transition declared on the compound,
21+
// guarded so it fires only once that compound is complete. The completion gate is
22+
// the guard: each region's final state bumps a counter, and the guard passes only
23+
// when every region has completed. selectEventless (fire.go) scans every active
24+
// config leaf and bubbles up its ancestor spine, so once the parallel completes
25+
// the final-leaf spines reach the enclosing compound and its guarded Always is
26+
// reachable. These tests lock that the done-transition (1) is taken once the
27+
// parallel completes, in the same macrostep, alongside par.OnDone and
28+
// outer.OnDone; and (2) is NOT taken while a region is still incomplete.
29+
30+
// edtCtx threads a completion counter the region finals increment; the enclosing
31+
// compound's done-transition guards on it so it fires only once both regions are
32+
// final.
33+
type edtCtx struct {
34+
done int
35+
current string
36+
}
37+
38+
// TestParallel_EnclosingDoneTransition_TakenOnParallelComplete drives the core
39+
// sub-case: a compound "outer" holds a parallel "par" (regions a, b) plus a
40+
// guarded done-transition outer --(Always, bothDone)--> next. When both regions
41+
// reach final in one macrostep, the run-to-completion loop must take the
42+
// done-transition to "next" within the SAME macrostep, and both par.OnDone
43+
// ("Pdone") and outer.OnDone ("Odone") must have fired exactly once.
44+
//
45+
// This is the transition analog of the OnDone cascade fixed in 94f3302: it
46+
// proves a parallel completing inside a compound routes through the same eventless
47+
// selection that a nested compound reaching final already used, so a done-
48+
// transition on the enclosing compound is not starved for the parallel case.
49+
func TestParallel_EnclosingDoneTransition_TakenOnParallelComplete(t *testing.T) {
50+
note := func(s string) state.ActionFn[edtCtx] {
51+
return func(state.ActionCtx[edtCtx]) (state.Effect, error) { return s, nil }
52+
}
53+
bump := func(c state.AssignCtx[edtCtx]) edtCtx { c.Entity.done++; return c.Entity }
54+
bothDone := func(c state.GuardCtx[edtCtx]) bool { return c.Entity.done >= 2 }
55+
56+
m := state.Forge[string, string, edtCtx]("par-enclosing-done-transition").
57+
Action("Pdone", note("Pdone")).
58+
Action("Odone", note("Odone")).
59+
Reducer("bump", bump).
60+
Guard("bothDone", bothDone).
61+
State("off").
62+
Transition("off").On("go").GoTo("par").
63+
SuperState("outer").OnDone("Odone").
64+
Initial("par").
65+
SuperState("par").OnDone("Pdone").
66+
Region("a").Initial("a1").SubState("a1").SubState("af").Final().OnEntryAssign("bump").EndRegion().
67+
Region("b").Initial("b1").SubState("b1").SubState("bf").Final().OnEntryAssign("bump").EndRegion().
68+
EndSuperState(). // close par
69+
// The done-TRANSITION on the enclosing compound: an eventless transition
70+
// guarded on completion. It is reachable only after the parallel completes,
71+
// so taking it proves the enclosing compound's done-transition fires for the
72+
// parallel-in-compound shape.
73+
Transition("outer").Always().When("bothDone").GoTo("next").
74+
EndSuperState(). // close outer
75+
State("next").
76+
Initial("off").
77+
CurrentStateFn(func(c edtCtx) string { return c.current }).
78+
Transition("a1").On("e").GoTo("af").
79+
Transition("b1").On("e").GoTo("bf").
80+
Quench()
81+
82+
inst := m.Cast(edtCtx{current: "off"}, state.WithInitialState("off"))
83+
ctx := context.Background()
84+
if res := inst.Fire(ctx, "go"); res.Err != nil {
85+
t.Fatalf("entering nested parallel: %v", res.Err)
86+
}
87+
88+
// Both regions reach final on the same event; the parallel completes, its
89+
// OnDone and the enclosing compound's OnDone settle, and the enclosing
90+
// compound's guarded done-transition must then fire to "next" — all in this one
91+
// macrostep.
92+
res := inst.Fire(ctx, "e")
93+
if res.Err != nil {
94+
t.Fatalf("e errored: %v (config=%v)", res.Err, inst.Configuration())
95+
}
96+
97+
// par.OnDone and outer.OnDone must each have fired exactly once.
98+
if got := countEffect(res.Effects, "Pdone"); got != 1 {
99+
t.Fatalf("par.OnDone fired %d times, want exactly 1 (effects=%v)", got, stringEffects(res.Effects))
100+
}
101+
if got := countEffect(res.Effects, "Odone"); got != 1 {
102+
t.Fatalf("outer.OnDone fired %d times, want exactly 1 (effects=%v)", got, stringEffects(res.Effects))
103+
}
104+
105+
// The done-transition was taken: the machine has left "outer" for "next".
106+
if got := inst.Configuration(); !configContains(got, "next") {
107+
t.Fatalf("done-transition not taken: config = %v, want [next]\n"+
108+
"the enclosing compound's guarded Always must fire once the parallel inside it completes",
109+
got)
110+
}
111+
if got := inst.Configuration(); configContains(got, "af") || configContains(got, "bf") || configContains(got, "par") || configContains(got, "outer") {
112+
t.Fatalf("done-transition left stale parallel leaves active: config = %v, want only [next]", got)
113+
}
114+
}
115+
116+
// TestParallel_EnclosingDoneTransition_NotTakenWhileIncomplete is the control: the
117+
// same shape, but only region a is driven to final. The parallel is therefore NOT
118+
// complete, the guard stays false, and the enclosing compound's done-transition
119+
// must NOT be taken — the machine stays inside "par"/"outer", and neither
120+
// par.OnDone nor outer.OnDone fires. This guards against an unguarded or
121+
// completion-blind done-transition firing prematurely.
122+
func TestParallel_EnclosingDoneTransition_NotTakenWhileIncomplete(t *testing.T) {
123+
note := func(s string) state.ActionFn[edtCtx] {
124+
return func(state.ActionCtx[edtCtx]) (state.Effect, error) { return s, nil }
125+
}
126+
bump := func(c state.AssignCtx[edtCtx]) edtCtx { c.Entity.done++; return c.Entity }
127+
bothDone := func(c state.GuardCtx[edtCtx]) bool { return c.Entity.done >= 2 }
128+
129+
m := state.Forge[string, string, edtCtx]("par-enclosing-done-incomplete").
130+
Action("Pdone", note("Pdone")).
131+
Action("Odone", note("Odone")).
132+
Reducer("bump", bump).
133+
Guard("bothDone", bothDone).
134+
State("off").
135+
Transition("off").On("go").GoTo("par").
136+
SuperState("outer").OnDone("Odone").
137+
Initial("par").
138+
SuperState("par").OnDone("Pdone").
139+
Region("a").Initial("a1").SubState("a1").SubState("af").Final().OnEntryAssign("bump").EndRegion().
140+
Region("b").Initial("b1").SubState("b1").SubState("bf").Final().OnEntryAssign("bump").EndRegion().
141+
EndSuperState().
142+
Transition("outer").Always().When("bothDone").GoTo("next").
143+
EndSuperState().
144+
State("next").
145+
Initial("off").
146+
CurrentStateFn(func(c edtCtx) string { return c.current }).
147+
Transition("a1").On("ea").GoTo("af").
148+
Transition("b1").On("eb").GoTo("bf").
149+
Quench()
150+
151+
inst := m.Cast(edtCtx{current: "off"}, state.WithInitialState("off"))
152+
ctx := context.Background()
153+
if res := inst.Fire(ctx, "go"); res.Err != nil {
154+
t.Fatalf("entering nested parallel: %v", res.Err)
155+
}
156+
157+
// Only region a reaches final. The parallel is not complete; the done-guard
158+
// stays false.
159+
res := inst.Fire(ctx, "ea")
160+
if res.Err != nil {
161+
t.Fatalf("ea errored: %v (config=%v)", res.Err, inst.Configuration())
162+
}
163+
164+
if got := countEffect(res.Effects, "Pdone"); got != 0 {
165+
t.Fatalf("par.OnDone fired %d times while region b is still active, want 0 (effects=%v)", got, stringEffects(res.Effects))
166+
}
167+
if got := countEffect(res.Effects, "Odone"); got != 0 {
168+
t.Fatalf("outer.OnDone fired %d times while the parallel is incomplete, want 0 (effects=%v)", got, stringEffects(res.Effects))
169+
}
170+
if got := inst.Configuration(); configContains(got, "next") {
171+
t.Fatalf("done-transition taken prematurely: config = %v, must not contain next while region b is still active", got)
172+
}
173+
if got := inst.Configuration(); !configContains(got, "af") || !configContains(got, "b1") {
174+
t.Fatalf("config = %v, want region a at final af and region b still at b1", got)
175+
}
176+
}
177+
178+
// configContains reports whether the settled configuration contains the named
179+
// leaf.
180+
func configContains(cfg []string, want string) bool {
181+
for _, l := range cfg {
182+
if l == want {
183+
return true
184+
}
185+
}
186+
return false
187+
}

0 commit comments

Comments
 (0)