Skip to content

Commit 1abe434

Browse files
committed
test: close state engine coverage gaps and fix two doc/label slips
Cover the fireFromState cross-cutting branches (commit, guard fail, guard panic, forbidden, unhandled), the full-trace mergeTrace path through FireSeq, the kernel typed Error and Unwrap methods, DiffMachines plus the evolution Serialize/Decode error types, Path.States, the conformance outcomeName branches, and the symbolic litNumber int forms. conformance outcomeName now renders OutcomeAssignFailed as AssignFailed instead of collapsing it to Unknown, so an assign failure stays distinct in a scenario trace. The analysis Step godoc, previously truncated mid-sentence, now describes the From/Event/To segment fully. Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent 7a803e1 commit 1abe434

9 files changed

Lines changed: 614 additions & 4 deletions

File tree

state/analysis/paths.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ import (
2323
// conformance harness draws from.
2424

2525
// Step is one segment of a path: the event that fires from the segment's source
26-
// state, and the destination state it leads to. A path is read as: starting at the
27-
// initial state, fire Step[0].Event to reach Step[0].To, then Step[1].Event, and so
28-
// on. A path segment carries its event plus the resulting
29-
// `state`).
26+
// state and the destination state it leads to. A path is read as: starting at the
27+
// initial state, fire Step[0].Event to reach Step[0].To, then Step[1].Event to
28+
// reach Step[1].To, and so on. Each segment carries its source state (From), the
29+
// fired event (Event), and the state reached (To).
3030
type Step struct {
3131
// Event is the string label of the event fired for this segment; "always" for an
3232
// eventless transition traversed on the path.

state/analysis/paths_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,42 @@ func TestShortestPaths_CoversAllReachable(t *testing.T) {
7171
}
7272
}
7373

74+
// TestPath_StatesAndEvents asserts Path.States renders the ordered states visited
75+
// (initial first, Target last) and stays consistent with Path.Events: a path of n
76+
// steps yields n events and n+1 states.
77+
func TestPath_StatesAndEvents(t *testing.T) {
78+
paths, err := analysis.ShortestPaths(feedbackMachine())
79+
if err != nil {
80+
t.Fatalf("ShortestPaths: %v", err)
81+
}
82+
83+
// The empty path (initial state reaching itself) is just the initial state.
84+
if got := paths["question"].States("question"); len(got) != 1 || got[0] != "question" {
85+
t.Fatalf("States for the empty path = %v, want [question]", got)
86+
}
87+
88+
// A two-step path to thanks via form: question -> form -> thanks.
89+
simple, err := analysis.SimplePaths(feedbackMachine())
90+
if err != nil {
91+
t.Fatalf("SimplePaths: %v", err)
92+
}
93+
viaForm := simple["thanks"][1] // sorted shortest-first; [1] is the two-step route
94+
states := viaForm.States("question")
95+
wantStates := []string{"question", "form", "thanks"}
96+
if len(states) != len(wantStates) {
97+
t.Fatalf("States = %v, want %v", states, wantStates)
98+
}
99+
for i := range wantStates {
100+
if states[i] != wantStates[i] {
101+
t.Fatalf("States[%d] = %q, want %q (full %v)", i, states[i], wantStates[i], states)
102+
}
103+
}
104+
// States is always one longer than Events (it includes the initial state).
105+
if len(states) != len(viaForm.Events())+1 {
106+
t.Fatalf("len(States)=%d, len(Events)=%d; want States == Events+1", len(states), len(viaForm.Events()))
107+
}
108+
}
109+
74110
// TestShortestPaths_MatchesPlanPath asserts that for each single target, the
75111
// shortest path's length matches the kernel's PlanPath length on the same guard-free
76112
// machine — ShortestPaths is the multi-target generalization of PlanPath.

state/conformance/internal_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package conformance
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stablekernel/crucible/state"
7+
)
8+
9+
// TestOutcomeName_AllKernelOutcomes asserts outcomeName renders every kernel
10+
// Outcome by a distinct stable name, so a conformance trace never collapses two
11+
// different failure classes onto the same label (which would make a regression
12+
// compare equal). An out-of-range value falls back to "Unknown".
13+
func TestOutcomeName_AllKernelOutcomes(t *testing.T) {
14+
cases := []struct {
15+
outcome state.Outcome
16+
want string
17+
}{
18+
{state.OutcomeSuccess, "Success"},
19+
{state.OutcomeInvalidTransition, "InvalidTransition"},
20+
{state.OutcomeGuardFailed, "GuardFailed"},
21+
{state.OutcomeGuardPanic, "GuardPanic"},
22+
{state.OutcomePolicyDenied, "PolicyDenied"},
23+
{state.OutcomeEffectError, "EffectError"},
24+
{state.OutcomeAssignFailed, "AssignFailed"},
25+
{state.Outcome(-1), "Unknown"},
26+
{state.Outcome(9999), "Unknown"},
27+
}
28+
29+
seen := map[string]state.Outcome{}
30+
for _, tc := range cases {
31+
got := outcomeName(tc.outcome)
32+
if got != tc.want {
33+
t.Fatalf("outcomeName(%d) = %q, want %q", tc.outcome, got, tc.want)
34+
}
35+
// Every named (non-Unknown) outcome must be distinct from the others, so a
36+
// trace diff distinguishes the failure classes.
37+
if tc.want != "Unknown" {
38+
if prev, dup := seen[got]; dup {
39+
t.Fatalf("outcomeName collision: %d and %d both render %q", prev, tc.outcome, got)
40+
}
41+
seen[got] = tc.outcome
42+
}
43+
}
44+
}

state/conformance/scenario.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,8 @@ func outcomeName(o state.Outcome) string {
242242
return "PolicyDenied"
243243
case state.OutcomeEffectError:
244244
return "EffectError"
245+
case state.OutcomeAssignFailed:
246+
return "AssignFailed"
245247
default:
246248
return "Unknown"
247249
}

state/errors_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package state_test
2+
3+
import (
4+
"errors"
5+
"strings"
6+
"testing"
7+
"time"
8+
9+
"github.com/stablekernel/crucible/state"
10+
)
11+
12+
// TestTypedErrors_Messages asserts every typed kernel error renders a non-empty,
13+
// self-describing message that names the offending entity. These are the
14+
// diagnostic surface a host logs and matches on, so the messages are part of the
15+
// contract.
16+
func TestTypedErrors_Messages(t *testing.T) {
17+
cases := []struct {
18+
name string
19+
err error
20+
contains []string
21+
}{
22+
{
23+
name: "InvalidTransition with target",
24+
err: &state.InvalidTransitionError{From: "a", To: "b", Event: "go", Reason: "guard failed"},
25+
contains: []string{"invalid transition", "a", "b", "go", "guard failed"},
26+
},
27+
{
28+
name: "InvalidTransition without target",
29+
err: &state.InvalidTransitionError{From: "a", Event: "go", Reason: "no transition"},
30+
contains: []string{"invalid transition", "a", "go", "no transition"},
31+
},
32+
{
33+
name: "GuardFailed",
34+
err: &state.GuardFailedError{GuardName: "isReady", Reason: "predicate returned false"},
35+
contains: []string{"guard", "isReady", "predicate returned false"},
36+
},
37+
{
38+
name: "GuardPanic",
39+
err: &state.GuardPanicError{GuardName: "isReady", Recovered: "nil deref"},
40+
contains: []string{"guard", "isReady", "panicked", "nil deref"},
41+
},
42+
{
43+
name: "AssignPanic",
44+
err: &state.AssignPanicError{AssignName: "fold", Recovered: "boom"},
45+
contains: []string{"assign", "fold", "panicked", "boom"},
46+
},
47+
{
48+
name: "PolicyDenied",
49+
err: &state.PolicyDeniedError{PolicyName: "rbac", Reason: "no role"},
50+
contains: []string{"policy", "rbac", "denied", "no role"},
51+
},
52+
{
53+
name: "UndeclaredState",
54+
err: &state.UndeclaredStateError{State: "ghost"},
55+
contains: []string{"undeclared state", "ghost"},
56+
},
57+
{
58+
name: "UnboundRef",
59+
err: &state.UnboundRefError{Kind: "guard", Name: "g"},
60+
contains: []string{"unbound", "guard", "g"},
61+
},
62+
{
63+
name: "MicrostepOverflow",
64+
err: &state.MicrostepOverflowError{Limit: 64, State: "loop"},
65+
contains: []string{"run-to-completion", "64", "loop"},
66+
},
67+
{
68+
name: "NoPath",
69+
err: &state.NoPathError{From: "a", To: "z"},
70+
contains: []string{"no path", "a", "z"},
71+
},
72+
{
73+
name: "WaitTimeout",
74+
err: &state.WaitTimeoutError{Machine: "m", Timeout: 5 * time.Second, Last: "idle"},
75+
contains: []string{"WaitFor", "m", "5s", "idle"},
76+
},
77+
{
78+
name: "NoInitialState",
79+
err: &state.NoInitialStateError{Machine: "m"},
80+
contains: []string{"cannot Cast", "m", "no CurrentStateFn"},
81+
},
82+
{
83+
name: "UnknownBuiltin",
84+
err: &state.UnknownBuiltinError{Name: "crucible.bogus"},
85+
contains: []string{"unknown built-in action", "crucible.bogus"},
86+
},
87+
{
88+
name: "UnboundActor",
89+
err: &state.UnboundActorError{Name: "child"},
90+
contains: []string{"unbound actor ref", "child"},
91+
},
92+
{
93+
name: "Snapshot with state",
94+
err: &state.SnapshotError{Op: "restore", State: "leaf", Reason: "not declared"},
95+
contains: []string{"snapshot restore", "leaf", "not declared"},
96+
},
97+
{
98+
name: "Snapshot without state",
99+
err: &state.SnapshotError{Op: "marshal", Reason: "encode failed"},
100+
contains: []string{"snapshot marshal", "encode failed"},
101+
},
102+
{
103+
name: "SnapshotVersion",
104+
err: &state.SnapshotVersionError{Kind: "machineVersion", Machine: "m", Got: "2", Want: "1", Reason: "major bump"},
105+
contains: []string{"version mismatch", "machineVersion", "m", "2", "1", "major bump"},
106+
},
107+
{
108+
name: "UnsupportedSchema",
109+
err: &state.UnsupportedSchemaError{Got: "2.0", Supported: "1.0"},
110+
contains: []string{"unsupported schema version", "2.0", "1.0"},
111+
},
112+
{
113+
name: "UnknownEffectKind",
114+
err: &state.UnknownEffectKindError{Kind: "foreign"},
115+
contains: []string{"unknown effect kind", "foreign"},
116+
},
117+
}
118+
119+
for _, tc := range cases {
120+
t.Run(tc.name, func(t *testing.T) {
121+
msg := tc.err.Error()
122+
if msg == "" {
123+
t.Fatal("error message is empty")
124+
}
125+
for _, want := range tc.contains {
126+
if !strings.Contains(msg, want) {
127+
t.Fatalf("message %q does not contain %q", msg, want)
128+
}
129+
}
130+
})
131+
}
132+
}
133+
134+
// TestActionFailedError_WrapsCause asserts ActionFailedError renders its context
135+
// and unwraps to the underlying cause for errors.Is / errors.As.
136+
func TestActionFailedError_WrapsCause(t *testing.T) {
137+
cause := errors.New("downstream boom")
138+
err := &state.ActionFailedError{TransitionName: "a->b", ActionName: "charge", Cause: cause}
139+
140+
msg := err.Error()
141+
for _, want := range []string{"action", "charge", "a->b", "downstream boom"} {
142+
if !strings.Contains(msg, want) {
143+
t.Fatalf("message %q does not contain %q", msg, want)
144+
}
145+
}
146+
if !errors.Is(err, cause) {
147+
t.Fatal("ActionFailedError should unwrap to its cause")
148+
}
149+
}
150+
151+
// TestMultiRegionError_AggregatesAndUnwraps asserts MultiRegionError renders each
152+
// region's message and exposes them for errors.As traversal.
153+
func TestMultiRegionError_AggregatesAndUnwraps(t *testing.T) {
154+
g := &state.GuardFailedError{GuardName: "g", Reason: "false"}
155+
a := &state.AssignPanicError{AssignName: "fold", Recovered: "boom"}
156+
err := &state.MultiRegionError{Errors: []error{g, a}}
157+
158+
msg := err.Error()
159+
for _, want := range []string{"2 regions errored", "g", "fold"} {
160+
if !strings.Contains(msg, want) {
161+
t.Fatalf("message %q does not contain %q", msg, want)
162+
}
163+
}
164+
165+
var gf *state.GuardFailedError
166+
if !errors.As(err, &gf) {
167+
t.Fatal("MultiRegionError should expose a *GuardFailedError via errors.As")
168+
}
169+
var ap *state.AssignPanicError
170+
if !errors.As(err, &ap) {
171+
t.Fatal("MultiRegionError should expose an *AssignPanicError via errors.As")
172+
}
173+
}

state/evolution/evolution_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,81 @@ func TestDiff_NestedChildAdded_Additive(t *testing.T) {
380380
}
381381
}
382382

383+
// TestDiffMachines_AgreesWithDiff drives the Quenched-machine entry point and
384+
// asserts it classifies a breaking change identically to Diff over the same IRs.
385+
func TestDiffMachines_AgreesWithDiff(t *testing.T) {
386+
oldM := state.Forge[string, string, any]("doc").
387+
State("draft").
388+
Transition("draft").On("submit").GoTo("review").
389+
State("review").
390+
Transition("review").On("approve").GoTo("done").
391+
State("done").Final().
392+
Initial("draft").
393+
Quench()
394+
// The updated machine drops the review->done transition: a breaking removal.
395+
newM := state.Forge[string, string, any]("doc").
396+
State("draft").
397+
Transition("draft").On("submit").GoTo("review").
398+
State("review").
399+
State("done").Final().
400+
Initial("draft").
401+
Quench()
402+
403+
r, err := evolution.DiffMachines(oldM, newM)
404+
if err != nil {
405+
t.Fatalf("DiffMachines: %v", err)
406+
}
407+
if !r.Breaking() {
408+
t.Fatalf("DiffMachines should report the removal as breaking, got:\n%s", r)
409+
}
410+
if !hasKind(r, evolution.KindTransitionRemoved) {
411+
t.Fatalf("expected transition_removed, got:\n%s", r)
412+
}
413+
}
414+
415+
// TestDiffMachines_IdenticalIsEmpty asserts DiffMachines over the same definition
416+
// reports no change.
417+
func TestDiffMachines_IdenticalIsEmpty(t *testing.T) {
418+
build := func() *state.Machine[string, string, any] {
419+
return state.Forge[string, string, any]("doc").
420+
State("draft").
421+
Transition("draft").On("submit").GoTo("done").
422+
State("done").Final().
423+
Initial("draft").
424+
Quench()
425+
}
426+
r, err := evolution.DiffMachines(build(), build())
427+
if err != nil {
428+
t.Fatalf("DiffMachines: %v", err)
429+
}
430+
if !r.Empty() {
431+
t.Fatalf("identical machines should diff empty, got:\n%s", r)
432+
}
433+
}
434+
435+
// TestEvolutionErrorTypes_FormatAndUnwrap covers the Error and Unwrap methods of
436+
// SerializeError and DecodeError directly, since a Machine that fails to serialize
437+
// cannot be produced through the normal Forge/Quench path.
438+
func TestEvolutionErrorTypes_FormatAndUnwrap(t *testing.T) {
439+
cause := errors.New("boom")
440+
441+
se := &evolution.SerializeError{Side: "old", Err: cause}
442+
if !strings.Contains(se.Error(), "serialize old machine") {
443+
t.Fatalf("SerializeError.Error() = %q", se.Error())
444+
}
445+
if !errors.Is(se, cause) {
446+
t.Fatal("SerializeError should unwrap to its cause")
447+
}
448+
449+
de := &evolution.DecodeError{Side: "new", Err: cause}
450+
if !strings.Contains(de.Error(), "decode new machine") {
451+
t.Fatalf("DecodeError.Error() = %q", de.Error())
452+
}
453+
if !errors.Is(de, cause) {
454+
t.Fatal("DecodeError should unwrap to its cause")
455+
}
456+
}
457+
383458
func TestDiffJSON_RoundTrip(t *testing.T) {
384459
old := docMachine()
385460
updated := docMachine()

0 commit comments

Comments
 (0)