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
130 changes: 130 additions & 0 deletions actions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package stateless

import (
"context"
"testing"
)

func TestStateMachine_Fire_IgnoredTriggerMustBeIgnoredInSubstate(t *testing.T) {
sm := NewStateMachine(stateB)
sm.Configure(stateA).
Permit(triggerX, stateC)

sm.Configure(stateB).
SubstateOf(stateA).
Ignore(triggerX)

sm.Fire(triggerX)

if got := sm.MustState(); got != stateB {
t.Errorf("sm.MustState() = %v, want %v", got, stateB)
}
}

func TestStateMachine_Fire_IgnoreIfTrue_TriggerMustBeIgnored(t *testing.T) {
sm := NewStateMachine(stateB)
sm.Configure(stateA).
Permit(triggerX, stateC)

sm.Configure(stateB).
SubstateOf(stateA).
Ignore(triggerX, func(_ context.Context, _ ...any) bool {
return true
})

sm.Fire(triggerX)

if got := sm.MustState(); got != stateB {
t.Errorf("sm.MustState() = %v, want %v", got, stateB)
}
}

func TestStateMachine_Fire_IgnoreIfFalse_TriggerMustNotBeIgnored(t *testing.T) {
sm := NewStateMachine(stateB)
sm.Configure(stateA).
Permit(triggerX, stateC)

sm.Configure(stateB).
SubstateOf(stateA).
Ignore(triggerX, func(_ context.Context, _ ...any) bool {
return false
})

sm.Fire(triggerX)

if got := sm.MustState(); got != stateC {
t.Errorf("sm.MustState() = %v, want %v", got, stateC)
}
}

func TestStateMachine_Fire_SuperStateShouldNotExitOnSubStateTransition(t *testing.T) {
sm := NewStateMachine(stateA)
record := []string{}

sm.Configure(stateA).
OnEntry(func(_ context.Context, _ ...any) error {
record = append(record, "Entered state A")
return nil
}).
OnExit(func(_ context.Context, _ ...any) error {
record = append(record, "Exited state A")
return nil
}).
Permit(triggerX, stateB)

sm.Configure(stateB). // Our super state
InitialTransition(stateC).
OnEntry(func(_ context.Context, _ ...any) error {
record = append(record, "Entered super state B")
return nil
Comment on lines +75 to +79

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This configuration block is not gofmt-formatted (indentation is inconsistent), which will create noisy diffs and diverge from standard Go formatting used elsewhere in the repo. Please run gofmt on this file.

Copilot uses AI. Check for mistakes.
}).
OnExit(func(_ context.Context, _ ...any) error {
record = append(record, "Exited super state B")
return nil
})

sm.Configure(stateC). // Our first sub state
SubstateOf(stateB).
OnEntry(func(_ context.Context, _ ...any) error {
record = append(record, "Entered sub state C")
return nil
}).
OnExit(func(_ context.Context, _ ...any) error {
record = append(record, "Exited sub state C")
return nil
}).
Permit(triggerY, stateD)

sm.Configure(stateD). // Our second sub state
SubstateOf(stateB).
OnEntry(func(_ context.Context, _ ...any) error {
record = append(record, "Entered sub state D")
return nil
}).
OnExit(func(_ context.Context, _ ...any) error {
record = append(record, "Exited sub state D")
return nil
})

sm.Fire(triggerX)
sm.Fire(triggerY)
Comment on lines +109 to +110

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both sm.Fire calls return an error but the test ignores it. If either trigger becomes unhandled or an action returns an error, the test could miss it depending on the recorded side effects. Assert both errors are nil.

This issue also appears in the following locations of the same file:

  • line 35
  • line 53
  • line 17

Copilot uses AI. Check for mistakes.

expected := []string{
"Exited state A",
"Entered super state B",
"Entered sub state C",
"Exited sub state C",
"Entered sub state D",
}

if len(record) != len(expected) {
t.Errorf("record length = %v, want %v", len(record), len(expected))
return
}

for i, v := range expected {
if record[i] != v {
t.Errorf("record[%d] = %v, want %v", i, record[i], v)
}
}
}
135 changes: 135 additions & 0 deletions transition_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package stateless

import (
"context"
"testing"
)

func TestStateMachine_Fire_TriggerHandledOnSuperStateAndSubState_UsesSubstateTransition(t *testing.T) {
sm := NewStateMachine(stateA)
sm.Configure(stateA).
Permit(triggerX, stateB)

sm.Configure(stateB).
SubstateOf(stateA).
Permit(triggerX, stateC)

sm.Fire(triggerX)
if got := sm.MustState(); got != stateB {
t.Errorf("sm.MustState() = %v, want %v", got, stateB)
}

Comment on lines +17 to +21

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StateMachine.Fire returns an error, but it’s ignored here. Please assert err == nil so the test fails if the trigger is unexpectedly unhandled or an action returns an error (apply similarly to the subsequent Fire call in this test).

This issue also appears in the following locations of the same file:

  • line 129
  • line 84
  • line 41
  • line 60

Copilot uses AI. Check for mistakes.
sm.Fire(triggerX)
if got := sm.MustState(); got != stateC {
t.Errorf("sm.MustState() = %v, want %v", got, stateC)
}
}

func TestStateMachine_Fire_TriggerHandledOnSuperStateAndSubState_SubstateGuardBlocked_UsesSuperstateTransition(t *testing.T) {
guardConditionValue := false
sm := NewStateMachine(stateB)

sm.Configure(stateA).
Permit(triggerX, stateD)

sm.Configure(stateB).
SubstateOf(stateA).
Permit(triggerX, stateC, func(_ context.Context, _ ...any) bool {
return guardConditionValue
})

sm.Fire(triggerX)
if got := sm.MustState(); got != stateD {
t.Errorf("sm.MustState() = %v, want %v", got, stateD)
}
}

func TestStateMachine_Fire_TriggerHandledOnSuperStateAndSubState_SubstateGuardOpen_UsesSubstateTransition(t *testing.T) {
guardConditionValue := true
sm := NewStateMachine(stateB)

sm.Configure(stateA).
Permit(triggerX, stateD)

sm.Configure(stateB).
SubstateOf(stateA).
Permit(triggerX, stateC, func(_ context.Context, _ ...any) bool {
return guardConditionValue
})

sm.Fire(triggerX)
if got := sm.MustState(); got != stateC {
t.Errorf("sm.MustState() = %v, want %v", got, stateC)
}
}

func TestStateMachine_InternalTransitionIf_ExecutesOnlyFirstMatchingAction(t *testing.T) {

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test name refers to InternalTransitionIf, but the API used is InternalTransition with guards. Consider renaming the test to match the public API surface (e.g., TestStateMachine_InternalTransition_...) to make it easier to find and understand.

Suggested change
func TestStateMachine_InternalTransitionIf_ExecutesOnlyFirstMatchingAction(t *testing.T) {
func TestStateMachine_InternalTransition_ExecutesOnlyFirstMatchingAction(t *testing.T) {

Copilot uses AI. Check for mistakes.
sm := NewStateMachine(1)
executed := []int{}

sm.Configure(1).
InternalTransition(1, func(_ context.Context, _ ...any) error {
executed = append(executed, 1)
return nil
}, func(_ context.Context, _ ...any) bool {
return true
}).
InternalTransition(1, func(_ context.Context, _ ...any) error {
executed = append(executed, 2)
return nil
}, func(_ context.Context, _ ...any) bool {
return false
})
Comment on lines +78 to +82

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current setup doesn’t actually verify any “first match wins” behavior: only the first internal transition’s guard can ever match (the second returns false). Also, the implementation panics when multiple trigger behaviours match the same trigger in a state, so configuring both guards to match would make this test crash rather than select the first. Consider either renaming this test to reflect what it checks (single guarded internal transition executes) or changing it to assert that multiple matching behaviours cause a panic / require mutually-exclusive guards.

Copilot uses AI. Check for mistakes.

sm.Fire(1)

if len(executed) != 1 || executed[0] != 1 {
t.Errorf("expected only first action to execute, got executions: %v", executed)
}
}

func TestStateMachine_Fire_MultiLayerSubstates_ClosestAncestorTransitionUsed(t *testing.T) {
tests := []struct {
name string
parentGuardConditionValue bool
childGuardConditionValue bool
grandchildGuardConditionValue bool
expectedState string
}{
{"GrandchildOpen", false, false, true, "GrandchildStateTarget"},
{"ChildOpen_GrandchildClosed", false, true, false, "ChildStateTarget"},
{"ChildOpen_GrandchildOpen", false, true, true, "GrandchildStateTarget"},
{"ParentOpen_ChildClosed_GrandchildClosed", true, false, false, "ParentStateTarget"},
{"ParentOpen_ChildClosed_GrandchildOpen", true, false, true, "GrandchildStateTarget"},
{"ParentOpen_ChildOpen_GrandchildClosed", true, true, false, "ChildStateTarget"},
{"AllOpen", true, true, true, "GrandchildStateTarget"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sm := NewStateMachine("GrandchildState")

sm.Configure("ParentState").
Permit(triggerX, "ParentStateTarget", func(_ context.Context, _ ...any) bool {
return tt.parentGuardConditionValue
})

sm.Configure("ChildState").
SubstateOf("ParentState").
Permit(triggerX, "ChildStateTarget", func(_ context.Context, _ ...any) bool {
return tt.childGuardConditionValue
})

sm.Configure("GrandchildState").
SubstateOf("ChildState").
Permit(triggerX, "GrandchildStateTarget", func(_ context.Context, _ ...any) bool {
return tt.grandchildGuardConditionValue
})

sm.Fire(triggerX)
if got := sm.MustState(); got != tt.expectedState {
t.Errorf("sm.MustState() = %v, want %v", got, tt.expectedState)
}
})
}
}
Loading