From 33f7c340a6a881c1ffcfdb91b03e22004acb06ed Mon Sep 17 00:00:00 2001 From: qmuntal Date: Fri, 6 Feb 2026 09:46:20 +0100 Subject: [PATCH] add more tests --- actions_test.go | 130 +++++++++++++++++++++++++++++++++++++++++++ transition_test.go | 135 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 actions_test.go create mode 100644 transition_test.go diff --git a/actions_test.go b/actions_test.go new file mode 100644 index 0000000..92e1303 --- /dev/null +++ b/actions_test.go @@ -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 + }). + 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) + + 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) + } + } +} diff --git a/transition_test.go b/transition_test.go new file mode 100644 index 0000000..330b3b9 --- /dev/null +++ b/transition_test.go @@ -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) + } + + 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) { + 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 + }) + + 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) + } + }) + } +}