Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions state/actor_output_spawninput_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package state_test

import (
"context"
"testing"

"github.com/stablekernel/crucible/state"
)

// This file pins two previously-uncovered exported actor seams:
//
// - ActorInstance.Output (actor.go:381, via the *actorAdapter the exported
// NewActor returns): it must return nil before the child reaches its final
// state and the extracted completion output once it does.
// - WithSpawnInput (options.go:125): the input map declared on a dynamic Spawn
// must reach the spawned actor's ActorBehavior verbatim, so a child can be
// seeded from it.

// TestOutput_NilBeforeFinal_ValueAfterFinal pins ActorInstance.Output across the
// completion boundary. We hold the ActorInstance the behavior returns so we can
// call Output() directly: before the child reaches "done" it must report nil, and
// once "finish" steps it to its final state the output extractor's value surfaces.
func TestOutput_NilBeforeFinal_ValueAfterFinal(t *testing.T) {
cm := state.Forge[string, string, *childEntity]("outchild").
Action("record", func(c state.ActionCtx[*childEntity]) (state.Effect, error) {
c.Entity.result = "done-output"
return nil, nil
}).
State("working").
State("done").Final().OnEntry("record").
Initial("working").
Transition("working").On("finish").GoTo("done").
Quench()

// Capture the ActorInstance returned by NewActor so Output() can be called on
// it directly across the completion boundary.
var captured state.ActorInstance
behavior := func(input map[string]any) (state.ActorInstance, error) {
inst := cm.Cast(&childEntity{}, state.WithInitialState("working"))
ai := state.NewActor(inst, func(i *state.Instance[string, string, *childEntity]) any {
return i.Entity().result
})
captured = ai
return ai, nil
}

parent := state.Forge[string, string, *trec]("outparent").
State("idle").
State("supervising").InvokeActor("outchild",
state.WithInvokeOnDone("childDone"), state.WithInvokeOnError("childErr")).
State("complete").
Initial("idle").
CurrentStateFn(func(*trec) string { return "idle" }).
Transition("idle").On("start").GoTo("supervising").
Transition("supervising").On("childDone").GoTo("complete").
Quench()

p := parent.Cast(&trec{}, state.WithInitialState("idle"))
sys := state.NewActorSystem(p).Register("outchild", behavior)
ctx := context.Background()

res := p.Fire(ctx, "start")
sys.Absorb(ctx, res.Effects)
if captured == nil {
t.Fatal("ActorBehavior was not invoked; no ActorInstance captured")
}

// Before the child reaches its final state, Output() must be nil.
if got := captured.Output(); got != nil {
t.Fatalf("Output() before final = %v, want nil", got)
}

id := state.ActorID(parent.Name(), "supervising", 0)
ref, ok := sys.Ref(id)
if !ok {
t.Fatalf("no actor ref for id %q", id)
}
if !sys.Deliver(ctx, ref, "finish") {
t.Fatal("Deliver(finish) returned false; actor not running")
}

// After the child reached "done" (final), Output() returns the extracted value.
if got := captured.Output(); got != "done-output" {
t.Fatalf("Output() after final = %v, want %q", got, "done-output")
}
if p.Current() != "complete" {
t.Fatalf("parent state = %q, want complete", p.Current())
}
}

// TestWithSpawnInput_ReachesSpawnedActor pins WithSpawnInput: the input map
// declared on a dynamic Spawn must arrive verbatim at the spawned actor's
// ActorBehavior. We capture the input the behavior receives and assert each key
// round-trips. The child is then driven to completion as an end-to-end sanity
// check that the spawn produced a live, addressable actor.
func TestWithSpawnInput_ReachesSpawnedActor(t *testing.T) {
cm := state.Forge[string, string, *childEntity]("inworker").
State("working").
State("done").Final().
Initial("working").
Transition("working").On("finish").GoTo("done").
Quench()

var received map[string]any
behavior := func(input map[string]any) (state.ActorInstance, error) {
received = input
inst := cm.Cast(&childEntity{}, state.WithInitialState("working"))
return state.NewActor(inst, nil), nil
}

m := state.Forge[string, string, *trec]("inspawner").
State("idle").
State("active").
Initial("idle").
CurrentStateFn(func(*trec) string { return "idle" }).
Transition("idle").On("go").GoTo("active").
Spawn("inworker", "worker-1",
state.WithSpawnInput(map[string]any{"greeting": "hello", "count": float64(42)}),
state.WithSpawnOnDone("workerDone"), state.WithSpawnOnError("workerErr")).
Transition("active").On("workerDone").GoTo("idle").
Quench()

parent := m.Cast(&trec{}, state.WithInitialState("idle"))
sys := state.NewActorSystem(parent).Register("inworker", behavior)
ctx := context.Background()

res := parent.Fire(ctx, "go")
sys.Absorb(ctx, res.Effects)

if received == nil {
t.Fatal("spawned ActorBehavior received nil input; WithSpawnInput did not reach the actor")
}
if got := received["greeting"]; got != "hello" {
t.Fatalf("input[greeting] = %v, want %q", got, "hello")
}
if got := received["count"]; got != float64(42) {
t.Fatalf("input[count] = %v, want %v", got, float64(42))
}
if len(received) != 2 {
t.Fatalf("input has %d keys (%v), want exactly 2", len(received), received)
}

// The spawn produced a live, addressable actor under the explicit id.
ref, ok := sys.Ref("worker-1")
if !ok {
t.Fatal("spawned actor worker-1 is not running/addressable")
}
if !sys.Deliver(ctx, ref, "finish") {
t.Fatal("Deliver(finish) returned false; spawned actor not running")
}
if sys.Running() != 0 {
t.Fatalf("running actors after completion = %d, want 0", sys.Running())
}
}
73 changes: 73 additions & 0 deletions state/forbid_any_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package state_test

import (
"context"
"testing"

"github.com/stablekernel/crucible/state"
)

// This file pins ForbidAny (kernel.go:1255): a forbidden wildcard on a state
// consumes EVERY event not otherwise handled there, ignoring it in place instead
// of bubbling to an ancestor that would handle it. It is the wildcard counterpart
// of Forbid: where Forbid blocks one named event, ForbidAny blocks all unhandled
// events.

// TestForbidAny_ConsumesEveryUnhandledEventWithoutBubbling asserts the ForbidAny
// contract against an ancestor that would otherwise handle the events. The child
// "work" declares ForbidAny. The parent "running" handles "stop" and "kick". Both
// are unhandled at the child, so — unlike a child with NO handler (which lets the
// event bubble), and unlike a plain Forbid (which blocks only one named event) —
// the forbidden wildcard must consume both in place. Neither bubbles to the parent.
func TestForbidAny_ConsumesEveryUnhandledEventWithoutBubbling(t *testing.T) {
m := provide(state.Forge[string, string, *trec]("forbidany").
State("idle").
Transition("idle").On("start").GoTo("running").
SuperState("running").
Initial("work").
Transition("running").On("stop").GoTo("halted").
Transition("running").On("kick").GoTo("halted").
SubState("work").
ForbidAny().
EndSuperState().
State("halted").
Initial("idle"))

inst := m.Cast(&trec{}, state.WithInitialState("running"))
if got := inst.Current(); got != "work" {
t.Fatalf("setup: Current() = %q, want work", got)
}

// "stop" is handled by the parent but unhandled at the child: ForbidAny consumes
// it in place, so it must NOT bubble to the parent's stop -> halted.
res := inst.Fire(context.Background(), "stop")
if res.Err != nil {
t.Fatalf("Fire(stop) err = %v, want nil (forbidden wildcard ignores it)", res.Err)
}
if res.NewState == "halted" {
t.Fatalf("forbidden-wildcard event 'stop' bubbled to ancestor: NewState = halted")
}
if inst.Current() != "work" {
t.Fatalf("Current() = %q, want work (forbidden wildcard consumed 'stop' in place)", inst.Current())
}

// "kick" is also handled by the parent but unhandled at the child: unlike plain
// Forbid (which blocks only one named event and lets other events bubble),
// ForbidAny blocks this one too — the defining difference between the two.
res = inst.Fire(context.Background(), "kick")
if res.Err != nil {
t.Fatalf("Fire(kick) err = %v, want nil (forbidden wildcard ignores it)", res.Err)
}
if res.NewState == "halted" {
t.Fatalf("forbidden-wildcard event 'kick' bubbled to ancestor: NewState = halted")
}
if inst.Current() != "work" {
t.Fatalf("Current() = %q, want work (forbidden wildcard consumed 'kick' in place)", inst.Current())
}

// The outcome of a forbidden (consumed) event is a clean success, mirroring the
// specific-Forbid contract (TestFireFromState_ForbiddenEventIsConsumed).
if res.Trace.Outcome != state.OutcomeSuccess {
t.Fatalf("forbidden-wildcard outcome = %v, want OutcomeSuccess", res.Trace.Outcome)
}
}
145 changes: 145 additions & 0 deletions state/guard_inspect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package state_test

import (
"testing"

"github.com/stablekernel/crucible/state"
)

// This file pins the guard-expression inspection accessors tooling depends on:
//
// - GuardNode.LeafRefs (guard.go:222): the named-ref leaves of a composite
// guard, left-to-right, with the config-aware stateIn built-in omitted.
// - GuardNode.StateInTargets (guard.go:227): the target states of every stateIn
// leaf, left-to-right.
//
// Both walk an And/Or/Not tree mixing named-ref leaves and stateIn leaves.

// TestGuardNode_LeafRefs extracts the named-ref leaves of composite guard trees,
// asserting left-to-right order and that stateIn leaves (which carry no host ref)
// are omitted.
func TestGuardNode_LeafRefs(t *testing.T) {
cases := []struct {
name string
expr state.GuardNode[string]
want []string
}{
{
name: "single named leaf",
expr: state.Guard[string]("a"),
want: []string{"a"},
},
{
name: "stateIn leaf carries no ref",
expr: state.StateIn("X"),
want: nil,
},
{
name: "And preserves left-to-right ref order",
expr: state.And(state.Guard[string]("a"), state.Guard[string]("b")),
want: []string{"a", "b"},
},
{
name: "Or over nested And, stateIn omitted",
expr: state.Or(
state.And(state.Guard[string]("a"), state.StateIn("X")),
state.Guard[string]("b"),
),
want: []string{"a", "b"},
},
{
name: "Not wrapping a named leaf",
expr: state.Not(state.Guard[string]("a")),
want: []string{"a"},
},
{
name: "deep mix of And/Or/Not and stateIn",
expr: state.And(
state.Guard[string]("a"),
state.Or(state.StateIn("X"), state.Not(state.Guard[string]("b"))),
state.Guard[string]("c"),
),
want: []string{"a", "b", "c"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
expr := tc.expr
refs := expr.LeafRefs()
got := make([]string, len(refs))
for i, r := range refs {
got[i] = r.Name
}
if !equalStrings(got, tc.want) {
t.Fatalf("LeafRefs() names = %v, want %v", got, tc.want)
}
})
}
}

// TestGuardNode_StateInTargets extracts the target states of every stateIn leaf in
// a composite guard, asserting left-to-right order and that named-ref leaves
// contribute nothing.
func TestGuardNode_StateInTargets(t *testing.T) {
cases := []struct {
name string
expr state.GuardNode[string]
want []string
}{
{
name: "single stateIn",
expr: state.StateIn("X"),
want: []string{"X"},
},
{
name: "named leaf has no stateIn target",
expr: state.Guard[string]("a"),
want: nil,
},
{
name: "And preserves left-to-right stateIn order",
expr: state.And(state.StateIn("X"), state.StateIn("Y")),
want: []string{"X", "Y"},
},
{
name: "Or over nested And/Not, named leaves omitted",
expr: state.Or(
state.And(state.Guard[string]("a"), state.StateIn("X")),
state.Not(state.StateIn("Y")),
),
want: []string{"X", "Y"},
},
{
name: "deep mix yields stateIn targets in spine order",
expr: state.And(
state.StateIn("X"),
state.Or(state.Guard[string]("a"), state.StateIn("Y")),
state.StateIn("Z"),
),
want: []string{"X", "Y", "Z"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
expr := tc.expr
got := expr.StateInTargets()
if !equalStrings(got, tc.want) {
t.Fatalf("StateInTargets() = %v, want %v", got, tc.want)
}
})
}
}

// equalStrings reports whether two string slices are element-wise equal, treating
// nil and empty as equal (an absence of refs/targets).
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
Loading
Loading