|
| 1 | +package state_test |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "errors" |
| 6 | + "testing" |
| 7 | + |
| 8 | + "github.com/stablekernel/crucible/state" |
| 9 | +) |
| 10 | + |
| 11 | +// This file raises proven coverage on settleParallelDone (parallel.go:521) by |
| 12 | +// driving, through the public Fire path, the two branches the v1.0 gap analysis |
| 13 | +// flagged as untested: |
| 14 | +// |
| 15 | +// - the OnDone-action ERROR branch: the parallel's own OnDone action fails, so |
| 16 | +// settleParallelDone must surface a typed *ActionFailedError tagged |
| 17 | +// "onDone:<parallel>" with OutcomeEffectError, rather than swallowing it. |
| 18 | +// - the upward-cascade branch: a parallel state nested *inside* a parent |
| 19 | +// compound, so once the parallel completes settleParallelDone runs and then |
| 20 | +// attempts to settle the parent's done (the `pn.hasParent` arm). |
| 21 | +// |
| 22 | +// Both branches are exercised end-to-end (no white-box reach-in) so the pins also |
| 23 | +// lock the observable Fire contract callers depend on. |
| 24 | + |
| 25 | +// TestParallel_OnDoneActionError_SurfacesTaggedFailure drives the error arm of |
| 26 | +// settleParallelDone: when every region reaches final in one macrostep and the |
| 27 | +// parallel's OnDone action returns an error, Fire must report a typed |
| 28 | +// *ActionFailedError whose TransitionName identifies the parallel's onDone and |
| 29 | +// whose cause unwraps to the original error, with OutcomeEffectError. A swallowed |
| 30 | +// done-error would be a soundness hole: the caller would believe the macrostep |
| 31 | +// succeeded while the OnDone effect never ran. |
| 32 | +func TestParallel_OnDoneActionError_SurfacesTaggedFailure(t *testing.T) { |
| 33 | + boom := errors.New("ondone-boom") |
| 34 | + m := state.Forge[string, string, prCtx]("par-ondone-error"). |
| 35 | + Action("failDone", func(state.ActionCtx[prCtx]) (state.Effect, error) { |
| 36 | + return nil, boom |
| 37 | + }). |
| 38 | + State("off"). |
| 39 | + Transition("off").On("go").GoTo("par"). |
| 40 | + SuperState("par").OnDone("failDone"). |
| 41 | + Region("a").Initial("a1").SubState("a1").SubState("af").Final().EndRegion(). |
| 42 | + Region("b").Initial("b1").SubState("b1").SubState("bf").Final().EndRegion(). |
| 43 | + EndSuperState(). |
| 44 | + Initial("off"). |
| 45 | + CurrentStateFn(func(prCtx) string { return "off" }). |
| 46 | + Transition("a1").On("e").GoTo("af"). |
| 47 | + Transition("b1").On("e").GoTo("bf"). |
| 48 | + Quench() |
| 49 | + |
| 50 | + inst := m.Cast(prCtx{}, state.WithInitialState("off")) |
| 51 | + ctx := context.Background() |
| 52 | + if res := inst.Fire(ctx, "go"); res.Err != nil { |
| 53 | + t.Fatalf("entering parallel: %v", res.Err) |
| 54 | + } |
| 55 | + |
| 56 | + // Both regions reach final on the same event; the parallel's OnDone then runs |
| 57 | + // and fails. |
| 58 | + res := inst.Fire(ctx, "e") |
| 59 | + |
| 60 | + var af *state.ActionFailedError |
| 61 | + if !errors.As(res.Err, &af) { |
| 62 | + t.Fatalf("err = %v, want *ActionFailedError", res.Err) |
| 63 | + } |
| 64 | + if !errors.Is(res.Err, boom) { |
| 65 | + t.Fatalf("err does not unwrap to boom: %v", res.Err) |
| 66 | + } |
| 67 | + if af.ActionName != "failDone" { |
| 68 | + t.Fatalf("ActionName = %q, want %q", af.ActionName, "failDone") |
| 69 | + } |
| 70 | + if af.TransitionName != "onDone:par" { |
| 71 | + t.Fatalf("TransitionName = %q, want %q (the parallel's onDone tag)", af.TransitionName, "onDone:par") |
| 72 | + } |
| 73 | + if res.Trace.Outcome != state.OutcomeEffectError { |
| 74 | + t.Fatalf("outcome = %v, want OutcomeEffectError", res.Trace.Outcome) |
| 75 | + } |
| 76 | +} |
| 77 | + |
| 78 | +// TestParallel_NestedUnderCompound_SettlesParentDone drives the upward-cascade arm |
| 79 | +// of settleParallelDone (`if pn.hasParent { settleDone(...) }`): a parallel "par" |
| 80 | +// is the sole child of a compound "outer". When both regions of "par" reach final |
| 81 | +// in one macrostep, settleParallelDone runs par.OnDone and then attempts to settle |
| 82 | +// "outer"'s done — so outer.OnDone must fire too, and exactly once. |
| 83 | +// |
| 84 | +// par.OnDone emits "Pdone"; outer.OnDone emits "Odone". After the completing event |
| 85 | +// the effect stream must contain BOTH, in inner-then-outer order, each exactly |
| 86 | +// once. |
| 87 | +// |
| 88 | +// FREEZE BLOCKER (BUG): this test is SKIPPED because it currently FAILS — it has |
| 89 | +// surfaced a real soundness bug in settleParallelDone's upward cascade. When a |
| 90 | +// parallel completes inside a compound, outer.OnDone is silently dropped (0 times, |
| 91 | +// want 1), even though stateComplete(outer) is true. The cascade arm calls |
| 92 | +// i.settleDone(parallel, ...), but settleDone guards on n.state.IsFinal and a |
| 93 | +// PARALLEL state is never IsFinal, so the call is a no-op. The sibling helper |
| 94 | +// settleInteriorDone correctly gates on stateComplete(parent) instead. A nested |
| 95 | +// *compound* reaching final DOES propagate (verified manually), so this is an |
| 96 | +// asymmetry, not intended behavior. The PIN is left in place (Skip, not deleted) |
| 97 | +// so it activates the moment the bug is fixed; it must NOT be deleted or weakened |
| 98 | +// to the buggy 0-count. See the agent return for full reproduction. |
| 99 | +func TestParallel_NestedUnderCompound_SettlesParentDone(t *testing.T) { |
| 100 | + t.Skip("FREEZE BLOCKER: settleParallelDone upward cascade drops the enclosing compound's OnDone " + |
| 101 | + "(settleDone gates on IsFinal, parallels are never IsFinal). Un-skip once fixed.") |
| 102 | + |
| 103 | + note := func(s string) state.ActionFn[prCtx] { |
| 104 | + return func(state.ActionCtx[prCtx]) (state.Effect, error) { return s, nil } |
| 105 | + } |
| 106 | + m := state.Forge[string, string, prCtx]("par-nested-done"). |
| 107 | + Action("Pdone", note("Pdone")). |
| 108 | + Action("Odone", note("Odone")). |
| 109 | + State("off"). |
| 110 | + Transition("off").On("go").GoTo("par"). |
| 111 | + SuperState("outer").OnDone("Odone"). |
| 112 | + Initial("par"). |
| 113 | + SuperState("par").OnDone("Pdone"). |
| 114 | + Region("a").Initial("a1").SubState("a1").SubState("af").Final().EndRegion(). |
| 115 | + Region("b").Initial("b1").SubState("b1").SubState("bf").Final().EndRegion(). |
| 116 | + EndSuperState(). // close par |
| 117 | + EndSuperState(). // close outer |
| 118 | + Initial("off"). |
| 119 | + CurrentStateFn(func(prCtx) string { return "off" }). |
| 120 | + Transition("a1").On("e").GoTo("af"). |
| 121 | + Transition("b1").On("e").GoTo("bf"). |
| 122 | + Quench() |
| 123 | + |
| 124 | + inst := m.Cast(prCtx{}, state.WithInitialState("off")) |
| 125 | + ctx := context.Background() |
| 126 | + if res := inst.Fire(ctx, "go"); res.Err != nil { |
| 127 | + t.Fatalf("entering nested parallel: %v", res.Err) |
| 128 | + } |
| 129 | + |
| 130 | + res := inst.Fire(ctx, "e") |
| 131 | + if res.Err != nil { |
| 132 | + t.Fatalf("e errored: %v (config=%v)", res.Err, inst.Configuration()) |
| 133 | + } |
| 134 | + |
| 135 | + // par.OnDone must fire exactly once. |
| 136 | + if got := countEffect(res.Effects, "Pdone"); got != 1 { |
| 137 | + t.Fatalf("par.OnDone fired %d times, want exactly 1 (effects=%v)", |
| 138 | + got, stringEffects(res.Effects)) |
| 139 | + } |
| 140 | + |
| 141 | + // The cascade-upward arm: once "par" completes, its enclosing compound "outer" |
| 142 | + // is also complete (par is outer's only active descendant and is done), so |
| 143 | + // outer.OnDone must fire — exactly once. |
| 144 | + if got := countEffect(res.Effects, "Odone"); got != 1 { |
| 145 | + t.Fatalf("outer.OnDone fired %d times after the nested parallel completed, want exactly 1 (effects=%v)\n"+ |
| 146 | + "the settleParallelDone upward-cascade arm must propagate done to the enclosing compound", |
| 147 | + got, stringEffects(res.Effects)) |
| 148 | + } |
| 149 | + |
| 150 | + // Inner-then-outer ordering: the parallel's own done settles before its parent's. |
| 151 | + got := stringEffects(res.Effects) |
| 152 | + pIdx, oIdx := -1, -1 |
| 153 | + for i, e := range got { |
| 154 | + switch e { |
| 155 | + case "Pdone": |
| 156 | + pIdx = i |
| 157 | + case "Odone": |
| 158 | + oIdx = i |
| 159 | + } |
| 160 | + } |
| 161 | + if pIdx < 0 || oIdx < 0 || pIdx >= oIdx { |
| 162 | + t.Fatalf("effect order = %v, want Pdone before Odone (inner done settles before the enclosing compound's done)", got) |
| 163 | + } |
| 164 | +} |
0 commit comments