Skip to content

Commit bd44bba

Browse files
committed
Add missing transitionTo to If/ElseIf/Else statements
1 parent d627bd3 commit bd44bba

6 files changed

Lines changed: 376 additions & 24 deletions

File tree

FunctionalStateMachine.Core.Tests/StateMachineAnalysisTests.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,56 @@ public void Validate_AllowsMultipleImmediateTransitionsWithoutCycle()
207207
Assert.NotNull(machine);
208208
}
209209

210+
[Fact]
211+
public void Validate_DetectsConditionalTransitionToAmbiguity()
212+
{
213+
var machine = StateMachine<State, Trigger, Data, CommandBase>.Create()
214+
.StartWith(State.A)
215+
.For(State.A)
216+
.On<Trigger.T1>()
217+
.If(data => data.Value > 5)
218+
.TransitionTo(State.B)
219+
.ElseIf(data => data.Value <= 5)
220+
.TransitionTo(State.C)
221+
.Done()
222+
.For(State.B)
223+
.On<Trigger.T1>()
224+
.TransitionTo(State.A)
225+
.For(State.C)
226+
.On<Trigger.T1>()
227+
.TransitionTo(State.A)
228+
.Build();
229+
230+
Assert.NotNull(machine);
231+
}
232+
233+
[Fact]
234+
public void Validate_DetectsMultipleTransitionToInSameTransition()
235+
{
236+
var exception = Assert.Throws<InvalidOperationException>(() =>
237+
{
238+
var machine = StateMachine<State, Trigger, Data, CommandBase>.Create()
239+
.StartWith(State.A)
240+
.For(State.A)
241+
.On<Trigger.T1>()
242+
.TransitionTo(State.B)
243+
.If(data => data.Value > 5)
244+
.Execute(() => new Command.Noop())
245+
.Else()
246+
.TransitionTo(State.C)
247+
.Done()
248+
.For(State.B)
249+
.On<Trigger.T1>()
250+
.TransitionTo(State.A)
251+
.For(State.C)
252+
.On<Trigger.T1>()
253+
.TransitionTo(State.A)
254+
.Build();
255+
});
256+
257+
Assert.Contains("TransitionTo", exception.Message, StringComparison.OrdinalIgnoreCase);
258+
}
259+
210260
[Fact]
211261
public void Validate_AllowsComplexReachableStateMachine()
212262
{

FunctionalStateMachine.Core.Tests/StateMachineConditionalTests.cs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,9 +224,54 @@ public void ElseIf_StopsAtFirstMatch()
224224
Assert.DoesNotContain("ElseIf2", executionLog);
225225
}
226226

227+
[Fact]
228+
public void If_ConditionalTransitionToRequiresSingleTarget()
229+
{
230+
var exception = Assert.Throws<InvalidOperationException>(() =>
231+
{
232+
var machine = StateMachine<State, Trigger, Data, CommandBase>.Create()
233+
.StartWith(State.Ready)
234+
.For(State.Ready)
235+
.On<Trigger.PayTrigger>()
236+
.If((data, trigger) => trigger.Amount >= 10m)
237+
.TransitionTo(State.Approved)
238+
.TransitionTo(State.Ready)
239+
.Done()
240+
.For(State.Approved)
241+
.On<Trigger.PayTrigger>()
242+
.Ignore()
243+
.Build();
244+
});
245+
246+
Assert.Contains("TransitionTo", exception.Message, StringComparison.OrdinalIgnoreCase);
247+
}
248+
249+
[Fact]
250+
public void If_AllowsSingleConditionalTransitionTo()
251+
{
252+
var machine = StateMachine<State, Trigger, Data, CommandBase>.Create()
253+
.StartWith(State.Ready)
254+
.For(State.Ready)
255+
.On<Trigger.PayTrigger>()
256+
.If((data, trigger) => trigger.Amount >= 10m)
257+
.TransitionTo(State.Approved)
258+
.Else()
259+
.Execute(() => new LogCommand("Declined"))
260+
.Done()
261+
.For(State.Approved)
262+
.On<Trigger.PayTrigger>()
263+
.Ignore()
264+
.Build();
265+
266+
var (newState, _, _) = machine.Fire(new Trigger.PayTrigger(4m), State.Ready, new Data(0m));
267+
268+
Assert.Equal(State.Ready, newState);
269+
}
270+
227271
private enum State
228272
{
229-
Ready
273+
Ready,
274+
Approved
230275
}
231276

232277
private abstract record Trigger

FunctionalStateMachine.Core/StateMachine.cs

Lines changed: 128 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -146,15 +146,23 @@ private bool TryFireInternal(
146146
return true;
147147
}
148148

149-
var targetState = transition.HasTargetState ? transition.TargetState! : currentState;
150-
targetState = ResolveInitialLeaf(targetState);
151-
152149
var commandList = new List<TCommand>();
153-
var isStateChange = transition.HasTargetState
150+
var updatedData = ApplyTransitionSteps(
151+
commandList,
152+
transition.Steps,
153+
currentState,
154+
currentData,
155+
trigger,
156+
out var conditionalHasTargetState,
157+
out var conditionalTargetState);
158+
var hasTargetState = transition.HasTargetState || conditionalHasTargetState;
159+
var targetState = transition.HasTargetState
160+
? transition.TargetState!
161+
: conditionalHasTargetState ? conditionalTargetState : currentState;
162+
targetState = ResolveInitialLeaf(targetState);
163+
var isStateChange = hasTargetState
154164
&& !EqualityComparer<TState>.Default.Equals(currentState, targetState);
155165

156-
var updatedData = ApplyTransitionSteps(commandList, transition, currentState, currentData, trigger);
157-
158166
if (isStateChange)
159167
{
160168
AppendExitCommands(commandList, currentState, targetState, updatedData);
@@ -260,12 +268,21 @@ internal void Validate(bool skipAnalysis = false)
260268
{
261269
foreach (var transition in definition.GetTransitions())
262270
{
263-
if (!transition.HasTargetState)
271+
if (!transition.HasTargetState && !HasTransitionTargetSteps(transition.Steps))
264272
{
265273
continue;
266274
}
267275

268-
if (!_states.ContainsKey(transition.TargetState!))
276+
foreach (var targetState in GetTransitionTargetStates(transition.Steps))
277+
{
278+
if (!_states.ContainsKey(targetState))
279+
{
280+
throw new InvalidOperationException(
281+
$"State '{definition.State}' transitions to '{targetState}', but it is not configured.");
282+
}
283+
}
284+
285+
if (transition.HasTargetState && !_states.ContainsKey(transition.TargetState!))
269286
{
270287
throw new InvalidOperationException(
271288
$"State '{definition.State}' transitions to '{transition.TargetState}', but it is not configured.");
@@ -538,6 +555,41 @@ private List<TState> GetStatesToRoot(TState start)
538555
return states;
539556
}
540557

558+
private static bool HasTransitionTargetSteps(List<TransitionStep> steps)
559+
{
560+
return GetTransitionTargetStates(steps).Count > 0;
561+
}
562+
563+
private static HashSet<TState> GetTransitionTargetStates(List<TransitionStep> steps)
564+
{
565+
var targets = new HashSet<TState>();
566+
CollectTransitionTargetStates(steps, targets);
567+
return targets;
568+
}
569+
570+
private static void CollectTransitionTargetStates(List<TransitionStep> steps, HashSet<TState> targets)
571+
{
572+
foreach (var step in steps)
573+
{
574+
switch (step.Kind)
575+
{
576+
case TransitionStepKind.Transition:
577+
targets.Add(step.TargetState!);
578+
break;
579+
case TransitionStepKind.Conditional:
580+
CollectTransitionTargetStates(step.ConditionalTrueSteps!, targets);
581+
CollectTransitionTargetStates(step.ConditionalFalseSteps!, targets);
582+
break;
583+
case TransitionStepKind.ConditionalChain:
584+
foreach (var branch in step.ConditionalBranches!)
585+
{
586+
CollectTransitionTargetStates(branch.Steps, targets);
587+
}
588+
break;
589+
}
590+
}
591+
}
592+
541593
private bool TryFindLowestCommonAncestor(TState currentState, TState targetState, out TState lca)
542594
{
543595
var currentChain = GetHierarchyChain(currentState);
@@ -635,7 +687,7 @@ private static void AppendCommands(
635687
}
636688

637689
var targetState = ResolveInitialLeaf(matched.TargetState!);
638-
var updatedData = ApplyTransitionSteps(commands, matched, state, data, trigger);
690+
var updatedData = ApplyTransitionSteps(commands, matched.Steps, state, data, trigger, out _, out _);
639691
var isStateChange = !EqualityComparer<TState>.Default.Equals(state, targetState);
640692

641693
if (isStateChange)
@@ -652,23 +704,17 @@ private static void AppendCommands(
652704
$"Immediate transition loop detected starting at '{currentState}'.");
653705
}
654706

655-
private static TData ApplyTransitionSteps(
656-
List<TCommand> commands,
657-
TransitionDefinition transition,
658-
TState currentState,
659-
TData currentData,
660-
TTrigger trigger)
661-
{
662-
return ApplyTransitionSteps(commands, transition.Steps, currentState, currentData, trigger);
663-
}
664-
665707
private static TData ApplyTransitionSteps(
666708
List<TCommand> commands,
667709
List<TransitionStep> steps,
668710
TState currentState,
669711
TData currentData,
670-
TTrigger trigger)
712+
TTrigger trigger,
713+
out bool hasTargetState,
714+
out TState targetState)
671715
{
716+
hasTargetState = false;
717+
targetState = default!;
672718
var updatedData = currentData;
673719
foreach (var step in steps)
674720
{
@@ -683,18 +729,46 @@ private static TData ApplyTransitionSteps(
683729
commands.Add(command);
684730
}
685731
break;
732+
case TransitionStepKind.Transition:
733+
hasTargetState = true;
734+
targetState = step.TargetState!;
735+
break;
686736
case TransitionStepKind.Conditional:
687737
var branch = step.Predicate!(currentState, updatedData, trigger)
688738
? step.ConditionalTrueSteps!
689739
: step.ConditionalFalseSteps!;
690-
updatedData = ApplyTransitionSteps(commands, branch, currentState, updatedData, trigger);
740+
updatedData = ApplyTransitionSteps(
741+
commands,
742+
branch,
743+
currentState,
744+
updatedData,
745+
trigger,
746+
out var conditionalHasTargetState,
747+
out var conditionalTargetState);
748+
if (conditionalHasTargetState && !hasTargetState)
749+
{
750+
hasTargetState = true;
751+
targetState = conditionalTargetState;
752+
}
691753
break;
692754
case TransitionStepKind.ConditionalChain:
693755
var matchedBranch = step.ConditionalBranches!.FirstOrDefault(b =>
694756
b.Predicate(currentState, updatedData, trigger));
695757
if (matchedBranch.Steps != null)
696758
{
697-
updatedData = ApplyTransitionSteps(commands, matchedBranch.Steps, currentState, updatedData, trigger);
759+
updatedData = ApplyTransitionSteps(
760+
commands,
761+
matchedBranch.Steps,
762+
currentState,
763+
updatedData,
764+
trigger,
765+
out var chainHasTargetState,
766+
out var chainTargetState);
767+
if (chainHasTargetState && !hasTargetState)
768+
{
769+
hasTargetState = true;
770+
targetState = chainTargetState;
771+
}
698772
}
699773
break;
700774
}
@@ -1439,6 +1513,12 @@ public ConditionalTransitionConfiguration Execute(Func<IEnumerable<TCommand>> ac
14391513
return Execute((state, data, trigger) => action());
14401514
}
14411515

1516+
public ConditionalTransitionConfiguration TransitionTo(TState state)
1517+
{
1518+
_currentBranchSteps!.Add(TransitionStep.ForTransition(state));
1519+
return this;
1520+
}
1521+
14421522
public ConditionalTransitionConfiguration ElseIf(Func<TData, TTrigger, bool> predicate)
14431523
{
14441524
_branches.Add(((state, data, trigger) => predicate(data, trigger), []));
@@ -1604,6 +1684,12 @@ public ConditionalTransitionConfiguration<TDerivedTrigger> Execute(Func<IEnumera
16041684
return Execute((state, data, trigger) => action());
16051685
}
16061686

1687+
public ConditionalTransitionConfiguration<TDerivedTrigger> TransitionTo(TState state)
1688+
{
1689+
_currentBranchSteps!.Add(TransitionStep.ForTransition(state));
1690+
return this;
1691+
}
1692+
16071693
public ConditionalTransitionConfiguration<TDerivedTrigger> ElseIf(
16081694
Func<TData, TDerivedTrigger, bool> predicate)
16091695
{
@@ -1736,6 +1822,7 @@ internal enum TransitionStepKind
17361822
{
17371823
ModifyData,
17381824
Execute,
1825+
Transition,
17391826
Conditional,
17401827
ConditionalChain
17411828
}
@@ -1755,6 +1842,8 @@ private TransitionStep(TransitionStepKind kind)
17551842

17561843
public Func<TState, TData, TTrigger, bool>? Predicate { get; private init; }
17571844

1845+
public TState? TargetState { get; private init; }
1846+
17581847
public List<TransitionStep>? ConditionalTrueSteps { get; private init; }
17591848

17601849
public List<TransitionStep>? ConditionalFalseSteps { get; private init; }
@@ -1771,6 +1860,11 @@ public static TransitionStep ForExecute(Func<TState, TData, TTrigger, IEnumerabl
17711860
return new TransitionStep(TransitionStepKind.Execute) { Executor = action };
17721861
}
17731862

1863+
public static TransitionStep ForTransition(TState targetState)
1864+
{
1865+
return new TransitionStep(TransitionStepKind.Transition) { TargetState = targetState };
1866+
}
1867+
17741868
public static TransitionStep ForConditional(
17751869
Func<TState, TData, TTrigger, bool> predicate,
17761870
List<TransitionStep> trueSteps,
@@ -2362,6 +2456,12 @@ public ConditionalTransitionConfiguration Execute(Func<IEnumerable<TCommand>> ac
23622456
return this;
23632457
}
23642458

2459+
public ConditionalTransitionConfiguration TransitionTo(TState state)
2460+
{
2461+
_inner.TransitionTo(state);
2462+
return this;
2463+
}
2464+
23652465
public ConditionalTransitionConfiguration Else()
23662466
{
23672467
_inner.Else();
@@ -2455,6 +2555,12 @@ public ConditionalTransitionConfiguration<TDerivedTrigger> Execute(Func<IEnumera
24552555
return this;
24562556
}
24572557

2558+
public ConditionalTransitionConfiguration<TDerivedTrigger> TransitionTo(TState state)
2559+
{
2560+
_inner.TransitionTo(state);
2561+
return this;
2562+
}
2563+
24582564
public ConditionalTransitionConfiguration<TDerivedTrigger> Else()
24592565
{
24602566
_inner.Else();

0 commit comments

Comments
 (0)