|
| 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