diff --git a/.gitignore b/.gitignore index 016904c..f8d7834 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,3 @@ obj/ .generated/ .vs/ .DS_Store -*.g.puml diff --git a/Chickensoft.LogicBlocks.Example/VendingMachine.cs b/Chickensoft.LogicBlocks.Example/VendingMachine.cs index 0970873..190586e 100644 --- a/Chickensoft.LogicBlocks.Example/VendingMachine.cs +++ b/Chickensoft.LogicBlocks.Example/VendingMachine.cs @@ -17,7 +17,7 @@ public abstract record State(Context Context) : StateLogic(Context) { public record Idle : State, IGet, IGet { public Idle(Context context) : base(context) { - context.OnEnter((previous) => context.Output( + OnEnter((previous) => context.Output( new Output.ClearTransactionTimeOutTimer() )); } @@ -55,7 +55,7 @@ public TransactionActive( Price = price; AmountReceived = amountReceived; - Context.OnEnter( + OnEnter( (previous) => Context.Output( new Output.RestartTransactionTimeOutTimer() ) @@ -97,7 +97,7 @@ public record Started : TransactionActive, public Started( Context context, ItemType type, int price, int amountReceived ) : base(context, type, price, amountReceived) { - context.OnEnter( + OnEnter( (previous) => context.Output(new Output.TransactionStarted()) ); } @@ -128,7 +128,7 @@ public Vending(Context context, ItemType type, int price) : Type = type; Price = price; - context.OnEnter( + OnEnter( (previous) => Context.Output(new Output.BeginVending()) ); } diff --git a/Chickensoft.LogicBlocks.Example/VendingMachine.g.puml b/Chickensoft.LogicBlocks.Example/VendingMachine.g.puml new file mode 100644 index 0000000..8bf947d --- /dev/null +++ b/Chickensoft.LogicBlocks.Example/VendingMachine.g.puml @@ -0,0 +1,32 @@ +@startuml VendingMachine +state "VendingMachine State" as Chickensoft_LogicBlocks_Example_VendingMachine_State { + state "Idle" as Chickensoft_LogicBlocks_Example_VendingMachine_State_Idle { + Chickensoft_LogicBlocks_Example_VendingMachine_State_Idle : OnEnter → ClearTransactionTimeOutTimer + Chickensoft_LogicBlocks_Example_VendingMachine_State_Idle : OnPaymentReceived → MakeChange + } + state "TransactionActive" as Chickensoft_LogicBlocks_Example_VendingMachine_State_TransactionActive { + state "Started" as Chickensoft_LogicBlocks_Example_VendingMachine_State_TransactionActive_Started { + Chickensoft_LogicBlocks_Example_VendingMachine_State_TransactionActive_Started : OnEnter → TransactionStarted + } + state "PaymentPending" as Chickensoft_LogicBlocks_Example_VendingMachine_State_TransactionActive_PaymentPending + Chickensoft_LogicBlocks_Example_VendingMachine_State_TransactionActive : OnEnter → RestartTransactionTimeOutTimer + Chickensoft_LogicBlocks_Example_VendingMachine_State_TransactionActive : OnPaymentReceived → MakeChange, TransactionCompleted + Chickensoft_LogicBlocks_Example_VendingMachine_State_TransactionActive : OnTransactionTimedOut → MakeChange + } + state "Vending" as Chickensoft_LogicBlocks_Example_VendingMachine_State_Vending { + Chickensoft_LogicBlocks_Example_VendingMachine_State_Vending : OnEnter → BeginVending + } +} + +Chickensoft_LogicBlocks_Example_VendingMachine_State_Idle --> Chickensoft_LogicBlocks_Example_VendingMachine_State_Idle : PaymentReceived +Chickensoft_LogicBlocks_Example_VendingMachine_State_Idle --> Chickensoft_LogicBlocks_Example_VendingMachine_State_Idle : SelectionEntered +Chickensoft_LogicBlocks_Example_VendingMachine_State_Idle --> Chickensoft_LogicBlocks_Example_VendingMachine_State_TransactionActive_Started : SelectionEntered +Chickensoft_LogicBlocks_Example_VendingMachine_State_TransactionActive --> Chickensoft_LogicBlocks_Example_VendingMachine_State_Idle : TransactionTimedOut +Chickensoft_LogicBlocks_Example_VendingMachine_State_TransactionActive --> Chickensoft_LogicBlocks_Example_VendingMachine_State_TransactionActive_PaymentPending : PaymentReceived +Chickensoft_LogicBlocks_Example_VendingMachine_State_TransactionActive --> Chickensoft_LogicBlocks_Example_VendingMachine_State_Vending : PaymentReceived +Chickensoft_LogicBlocks_Example_VendingMachine_State_TransactionActive_Started --> Chickensoft_LogicBlocks_Example_VendingMachine_State_Idle : SelectionEntered +Chickensoft_LogicBlocks_Example_VendingMachine_State_TransactionActive_Started --> Chickensoft_LogicBlocks_Example_VendingMachine_State_TransactionActive_Started : SelectionEntered +Chickensoft_LogicBlocks_Example_VendingMachine_State_Vending --> Chickensoft_LogicBlocks_Example_VendingMachine_State_Idle : VendingCompleted + +[*] --> Chickensoft_LogicBlocks_Example_VendingMachine_State_Idle +@enduml \ No newline at end of file diff --git a/Chickensoft.LogicBlocks.Generator.Tests/GeneratorTest.cs b/Chickensoft.LogicBlocks.Generator.Tests/GeneratorTest.cs index e22b583..5f817fc 100644 --- a/Chickensoft.LogicBlocks.Generator.Tests/GeneratorTest.cs +++ b/Chickensoft.LogicBlocks.Generator.Tests/GeneratorTest.cs @@ -11,7 +11,7 @@ public void GeneratesUml() { result.Outputs["ToasterOven.puml.g.cs"].ShouldBe(""" @startuml ToasterOven - state "ToasterOven" as State { + state "ToasterOven State" as Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State { state "Heating" as Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Heating { state "Toasting" as Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Toasting { Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Toasting : OnEnter → SetTimer diff --git a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/Heater.cs b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/Heater.cs index bbd63c2..c552f55 100644 --- a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/Heater.cs +++ b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/Heater.cs @@ -52,26 +52,25 @@ public Heating(Context context, double targetTemp) : base( ) { var tempSensor = context.Get(); - context.OnEnter( + OnEnter( (previous) => tempSensor.OnTemperatureChanged += OnTemperatureChanged ); - context.OnExit( + OnExit( (next) => tempSensor.OnTemperatureChanged -= OnTemperatureChanged ); } - State IGet.On(Input.TurnOff input) - => new Off(Context, TargetTemp); + public State On(Input.TurnOff input) => new Off(Context, TargetTemp); - State IGet.On( - Input.AirTempSensorChanged input - ) => input.AirTemp >= TargetTemp - ? new Idle(Context, TargetTemp) - : this; + public State On(Input.AirTempSensorChanged input) => + input.AirTemp >= TargetTemp + ? new Idle(Context, TargetTemp) + : this; - State IGet.On(Input.TargetTempChanged input) - => this with { TargetTemp = input.Temp }; + public State On(Input.TargetTempChanged input) => this with { + TargetTemp = input.Temp + }; private void OnTemperatureChanged(double airTemp) { Context.Input(new Input.AirTempSensorChanged(airTemp)); diff --git a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/Heater.g.puml b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/Heater.g.puml new file mode 100644 index 0000000..377265f --- /dev/null +++ b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/Heater.g.puml @@ -0,0 +1,17 @@ +@startuml Heater +state "Heater State" as Chickensoft_LogicBlocks_Generator_Tests_Heater_State { + state "Off" as Chickensoft_LogicBlocks_Generator_Tests_Heater_State_Off + state "Idle" as Chickensoft_LogicBlocks_Generator_Tests_Heater_State_Idle + state "Heating" as Chickensoft_LogicBlocks_Generator_Tests_Heater_State_Heating { + Chickensoft_LogicBlocks_Generator_Tests_Heater_State_Heating : OnTemperatureChanged() → AirTempChanged + } +} + +Chickensoft_LogicBlocks_Generator_Tests_Heater_State_Heating --> Chickensoft_LogicBlocks_Generator_Tests_Heater_State_Heating : AirTempSensorChanged +Chickensoft_LogicBlocks_Generator_Tests_Heater_State_Heating --> Chickensoft_LogicBlocks_Generator_Tests_Heater_State_Heating : TargetTempChanged +Chickensoft_LogicBlocks_Generator_Tests_Heater_State_Heating --> Chickensoft_LogicBlocks_Generator_Tests_Heater_State_Idle : AirTempSensorChanged +Chickensoft_LogicBlocks_Generator_Tests_Heater_State_Heating --> Chickensoft_LogicBlocks_Generator_Tests_Heater_State_Off : TurnOff +Chickensoft_LogicBlocks_Generator_Tests_Heater_State_Off --> Chickensoft_LogicBlocks_Generator_Tests_Heater_State_Heating : TurnOn + +[*] --> Chickensoft_LogicBlocks_Generator_Tests_Heater_State_Off +@enduml \ No newline at end of file diff --git a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/LightSwitch.g.puml b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/LightSwitch.g.puml new file mode 100644 index 0000000..5efe93a --- /dev/null +++ b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/LightSwitch.g.puml @@ -0,0 +1,11 @@ +@startuml LightSwitch +state "LightSwitch State" as Chickensoft_LogicBlocks_Generator_Tests_LightSwitch_State { + state "On" as Chickensoft_LogicBlocks_Generator_Tests_LightSwitch_State_On + state "Off" as Chickensoft_LogicBlocks_Generator_Tests_LightSwitch_State_Off +} + +Chickensoft_LogicBlocks_Generator_Tests_LightSwitch_State_Off --> Chickensoft_LogicBlocks_Generator_Tests_LightSwitch_State_On : Toggle +Chickensoft_LogicBlocks_Generator_Tests_LightSwitch_State_On --> Chickensoft_LogicBlocks_Generator_Tests_LightSwitch_State_Off : Toggle + +[*] --> Chickensoft_LogicBlocks_Generator_Tests_LightSwitch_State_Off +@enduml \ No newline at end of file diff --git a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/ToasterOven.cs b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/ToasterOven.cs index fdeca64..635fb45 100644 --- a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/ToasterOven.cs +++ b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/ToasterOven.cs @@ -15,10 +15,10 @@ public record StartToasting(int ToastColor) : Input; public abstract record State(Context Context) : StateLogic(Context) { public record Heating : State, IGet { public Heating(Context context) : base(context) { - Context.OnEnter( + OnEnter( (previous) => Context.Output(new Output.TurnHeaterOn()) ); - Context.OnExit( + OnExit( (next) => Context.Output(new Output.TurnHeaterOff()) ); } @@ -32,10 +32,10 @@ public record Toasting : Heating, IGet { public Toasting(Context context, int toastColor) : base(context) { ToastColor = toastColor; - Context.OnEnter( + OnEnter( (previous) => Context.Output(new Output.SetTimer(ToastColor)) ); - Context.OnExit( + OnExit( (next) => Context.Output(new Output.ResetTimer()) ); } @@ -51,10 +51,10 @@ public record Baking : Heating, IGet { public Baking(Context context, int temperature) : base(context) { Temperature = temperature; - Context.OnEnter( + OnEnter( (previous) => Context.Output(new Output.SetTemperature(Temperature)) ); - Context.OnExit( + OnExit( (next) => Context.Output(new Output.SetTemperature(0)) ); } @@ -66,10 +66,10 @@ public Baking(Context context, int temperature) : base(context) { public record DoorOpen : State, IGet { public DoorOpen(Context context) : base(context) { - Context.OnEnter( + OnEnter( (previous) => Context.Output(new Output.TurnLampOn()) ); - Context.OnExit( + OnExit( (next) => Context.Output(new Output.TurnLampOff()) ); } diff --git a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/ToasterOven.g.puml b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/ToasterOven.g.puml new file mode 100644 index 0000000..1a1849f --- /dev/null +++ b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/ToasterOven.g.puml @@ -0,0 +1,27 @@ +@startuml ToasterOven +state "ToasterOven State" as Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State { + state "Heating" as Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Heating { + state "Toasting" as Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Toasting { + Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Toasting : OnEnter → SetTimer + Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Toasting : OnExit → ResetTimer + } + state "Baking" as Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Baking { + Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Baking : OnEnter → SetTemperature + Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Baking : OnExit → SetTemperature + } + Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Heating : OnEnter → TurnHeaterOn + Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Heating : OnExit → TurnHeaterOff + } + state "DoorOpen" as Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_DoorOpen { + Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_DoorOpen : OnEnter → TurnLampOn + Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_DoorOpen : OnExit → TurnLampOff + } +} + +Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Baking --> Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Toasting : StartToasting +Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_DoorOpen --> Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Toasting : CloseDoor +Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Heating --> Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_DoorOpen : OpenDoor +Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Toasting --> Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Baking : StartBaking + +[*] --> Chickensoft_LogicBlocks_Generator_Tests_ToasterOven_State_Toasting +@enduml \ No newline at end of file diff --git a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/partial_split_across_files/PartialLogic1.cs b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/partial_split_across_files/PartialLogic1.cs index c7fd9be..d2746de 100644 --- a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/partial_split_across_files/PartialLogic1.cs +++ b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/partial_split_across_files/PartialLogic1.cs @@ -16,5 +16,6 @@ public abstract record Output { public record OutputA : Output; public record OutputEnterA : Output; public record OutputExitA : Output; + public record OutputSomething : Output; } } diff --git a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/partial_split_across_files/PartialLogic1.g.puml b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/partial_split_across_files/PartialLogic1.g.puml new file mode 100644 index 0000000..f71862a --- /dev/null +++ b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/partial_split_across_files/PartialLogic1.g.puml @@ -0,0 +1,13 @@ +@startuml PartialLogic +state "PartialLogic State" as Chickensoft_LogicBlocks_Generator_Tests_PartialLogic_State { + state "A" as Chickensoft_LogicBlocks_Generator_Tests_PartialLogic_State_A { + Chickensoft_LogicBlocks_Generator_Tests_PartialLogic_State_A : DoSomething() → OutputSomething + Chickensoft_LogicBlocks_Generator_Tests_PartialLogic_State_A : OnEnter → OutputEnterA + Chickensoft_LogicBlocks_Generator_Tests_PartialLogic_State_A : OnExit → OutputExitA + Chickensoft_LogicBlocks_Generator_Tests_PartialLogic_State_A : OnOne → OutputA + } + state "B" as Chickensoft_LogicBlocks_Generator_Tests_PartialLogic_State_B +} + +Chickensoft_LogicBlocks_Generator_Tests_PartialLogic_State_A --> Chickensoft_LogicBlocks_Generator_Tests_PartialLogic_State_B : One +@enduml \ No newline at end of file diff --git a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/partial_split_across_files/PartialLogic2.cs b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/partial_split_across_files/PartialLogic2.cs index 1cfaff9..fdcfd45 100644 --- a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/partial_split_across_files/PartialLogic2.cs +++ b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/partial_split_across_files/PartialLogic2.cs @@ -5,10 +5,10 @@ public partial class PartialLogic : public abstract partial record State : StateLogic { public partial record A : State, IGet { public A(Context context) : base(context) { - Context.OnEnter( + OnEnter( (previous) => Context.Output(new Output.OutputEnterA()) ); - Context.OnExit( + OnExit( (next) => Context.Output(new Output.OutputExitA()) ); } diff --git a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/partial_split_across_files/PartialLogic3.cs b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/partial_split_across_files/PartialLogic3.cs index 0934486..26bf3be 100644 --- a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/partial_split_across_files/PartialLogic3.cs +++ b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/partial_split_across_files/PartialLogic3.cs @@ -8,6 +8,8 @@ public State On(Input.One input) { Context.Output(new Output.OutputA()); return new B(Context); } + + public void DoSomething() => Context.Output(new Output.OutputSomething()); } } } diff --git a/Chickensoft.LogicBlocks.Generator/Chickensoft.LogicBlocks.Generator.csproj b/Chickensoft.LogicBlocks.Generator/Chickensoft.LogicBlocks.Generator.csproj index 22df764..48c7dbc 100644 --- a/Chickensoft.LogicBlocks.Generator/Chickensoft.LogicBlocks.Generator.csproj +++ b/Chickensoft.LogicBlocks.Generator/Chickensoft.LogicBlocks.Generator.csproj @@ -11,7 +11,7 @@ NU5128 LogicBlocks Generator - 1.3.0 + 2.0.0-beta.1 © 2023 Chickensoft Games Chickensoft diff --git a/Chickensoft.LogicBlocks.Generator/src/LogicBlocksGenerator.cs b/Chickensoft.LogicBlocks.Generator/src/LogicBlocksGenerator.cs index 887b91f..77bded0 100644 --- a/Chickensoft.LogicBlocks.Generator/src/LogicBlocksGenerator.cs +++ b/Chickensoft.LogicBlocks.Generator/src/LogicBlocksGenerator.cs @@ -3,6 +3,7 @@ namespace Chickensoft.LogicBlocks.Generator; using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics; using System.IO; using System.Linq; using System.Text; @@ -30,7 +31,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { // the source generator process is started by running `dotnet build` in // the project consuming the source generator // - // Debugger.Launch(); + Debugger.Launch(); // Add post initialization sources // (source code that is always generated regardless) @@ -235,20 +236,22 @@ outputType is not INamedTypeSymbol outputBaseType member.Name == Constants.LOGIC_BLOCK_GET_INITIAL_STATE ); - string? initialStateId = null; + HashSet initialStateIds = new(); if ( getInitialStateMethod is IMethodSymbol initialStateMethod && - initialStateMethod - .DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax(token) is - SyntaxNode initialStateMethodNode + initialStateMethod.DeclaringSyntaxReferences.Select( + (syntaxRef) => syntaxRef.GetSyntax(token) + ).OfType() is + IEnumerable initialStateMethodSyntaxes ) { - var initialStateVisitor = new ReturnTypeVisitor( - model, token, CodeService, stateBaseType - ); - initialStateVisitor.Visit(initialStateMethodNode); - initialStateId = initialStateVisitor.ReturnTypes.FirstOrDefault(); - Log.Print($"Initial state type: {initialStateId}"); + foreach (var initialStateMethodSyntax in initialStateMethodSyntaxes) { + var initialStateVisitor = new ReturnTypeVisitor( + model, token, CodeService, stateBaseType + ); + initialStateVisitor.Visit(initialStateMethodSyntax); + initialStateIds.UnionWith(initialStateVisitor.ReturnTypes); + } } // Convert the subtypes into a graph by recursively building the graph @@ -302,7 +305,7 @@ LogicBlockGraph buildGraph( FilePath: destFile, Id: CodeService.GetNameFullyQualified(symbol, symbol.Name), Name: symbol.Name, - InitialStateId: initialStateId, + InitialStateIds: initialStateIds.ToImmutableHashSet(), Graph: root, Inputs: inputs.ToImmutableDictionary(), Outputs: outputs.ToImmutableDictionary(), @@ -342,10 +345,13 @@ CancellationToken token transitions.Sort(); - var initialStateString = implementation.InitialStateId != null - ? "[*] --> " + - $"{implementation.StatesById[implementation.InitialStateId].UmlId}" - : ""; + var initialStates = new List(); + + foreach (var initialStateId in implementation.InitialStateIds) { + initialStates.Add( + "[*] --> " + implementation.StatesById[initialStateId].UmlId + ); + } var text = Format($""" @startuml {implementation.Name} @@ -353,7 +359,7 @@ CancellationToken token {transitions} - {initialStateString} + {initialStates} @enduml """); @@ -379,7 +385,7 @@ int t if (isMultilineState) { if (isRoot) { lines.Add( - $"{Tab(t)}state \"{impl.Name}\" as {graph.Name} {{" + $"{Tab(t)}state \"{impl.Name} State\" as {graph.UmlId} {{" ); } else { @@ -387,7 +393,7 @@ int t } } else if (isRoot) { - lines.Add($"{Tab(t)}state \"{impl.Name} {graph.Name}\" as {graph.Name}"); + lines.Add($"{Tab(t)}state \"{impl.Name} State\" as {graph.UmlId}"); } else { lines.Add($"{Tab(t)}state \"{graph.Name}\" as {graph.UmlId}"); @@ -443,11 +449,11 @@ private Dictionary GetSubclassesById( } public StatesAndOutputs GetStatesAndOutputs( - INamedTypeSymbol type, - SemanticModel model, - CancellationToken token, - INamedTypeSymbol stateBaseType - ) { + INamedTypeSymbol type, + SemanticModel model, + CancellationToken token, + INamedTypeSymbol stateBaseType + ) { // type is the state type var inputToStatesBuilder = ImmutableDictionary @@ -477,12 +483,6 @@ Constants.LOGIC_BLOCK_INPUT_INTERFACE_ID or .SelectMany(syntaxNode => syntaxNode.ChildNodes()) .OfType().ToList(); - var handledInputInterfaceSyntaxes = handledInputInterfaces - .SelectMany( - interfaceType => interfaceType.DeclaringSyntaxReferences - .Select(syntaxRef => syntaxRef.GetSyntax(token)) - ); - var inputHandlerMethods = new List(); var outputVisitor = new OutputVisitor( @@ -507,38 +507,46 @@ Constants.LOGIC_BLOCK_INPUT_INTERFACE_ID or if (implementation is not IMethodSymbol methodSymbol) { continue; } - var handlerMethodSyntax = methodSymbol + + var handlerMethodSyntaxes = methodSymbol .DeclaringSyntaxReferences - .FirstOrDefault()? - .GetSyntax(token) as MethodDeclarationSyntax; - if (handlerMethodSyntax is not MethodDeclarationSyntax methodSyntax) { + .Select(syntaxRef => syntaxRef.GetSyntax(token)) + .OfType() + .ToImmutableArray(); + + if (handlerMethodSyntaxes.Length == 0) { continue; } - inputHandlerMethods.Add(methodSyntax); - var inputId = CodeService.GetNameFullyQualifiedWithoutGenerics( - inputType, inputType.Name - ); - var outputContext = OutputContexts.OnInput(inputType.Name); - var returnTypeVisitor = new ReturnTypeVisitor( - model, token, CodeService, stateBaseType - ); - outputVisitor = new OutputVisitor( - model, token, CodeService, outputContext - ); - returnTypeVisitor.Visit(methodSyntax); - outputVisitor.Visit(methodSyntax); + foreach (var methodSyntax in handlerMethodSyntaxes) { + inputHandlerMethods.Add(methodSyntax); + var inputId = CodeService.GetNameFullyQualifiedWithoutGenerics( + inputType, inputType.Name + ); + var outputContext = OutputContexts.OnInput(inputType.Name); + var modelForSyntax = + model.Compilation.GetSemanticModel(methodSyntax.SyntaxTree); + var returnTypeVisitor = new ReturnTypeVisitor( + modelForSyntax, token, CodeService, stateBaseType + ); + outputVisitor = new OutputVisitor( + modelForSyntax, token, CodeService, outputContext + ); + + returnTypeVisitor.Visit(methodSyntax); + outputVisitor.Visit(methodSyntax); - if (outputVisitor.OutputTypes.ContainsKey(outputContext)) { - outputsBuilder.Add( - outputContext, outputVisitor.OutputTypes[outputContext] + if (outputVisitor.OutputTypes.ContainsKey(outputContext)) { + outputsBuilder.Add( + outputContext, outputVisitor.OutputTypes[outputContext] + ); + } + + inputToStatesBuilder.Add( + inputId, + returnTypeVisitor.ReturnTypes ); } - - inputToStatesBuilder.Add( - inputId, - returnTypeVisitor.ReturnTypes - ); } // find methods on type that aren't input handlers or constructors @@ -553,8 +561,12 @@ Constants.LOGIC_BLOCK_INPUT_INTERFACE_ID or Log.Print("Examining method: " + otherMethod.Identifier.Text); var outputContext = OutputContexts.Method(otherMethod.Identifier.Text); + var modelForSyntax = model.Compilation.GetSemanticModel( + otherMethod.SyntaxTree + ); + outputVisitor = new OutputVisitor( - model, token, CodeService, outputContext + modelForSyntax, token, CodeService, outputContext ); outputVisitor.Visit(otherMethod); diff --git a/Chickensoft.LogicBlocks.Generator/src/OutputVisitor.cs b/Chickensoft.LogicBlocks.Generator/src/OutputVisitor.cs index 10e3787..1564252 100644 --- a/Chickensoft.LogicBlocks.Generator/src/OutputVisitor.cs +++ b/Chickensoft.LogicBlocks.Generator/src/OutputVisitor.cs @@ -41,28 +41,73 @@ IOutputContext startContext public override void VisitInvocationExpression( InvocationExpressionSyntax node ) { - if (node.Expression is not MemberAccessExpressionSyntax memberAccess) { + var methodName = ""; + if (node.Expression is MemberAccessExpressionSyntax memberAccess) { + var id = memberAccess.Expression; + if (id is not IdentifierNameSyntax identifierName) { + base.VisitInvocationExpression(node); + return; + } + + var lhsType = + GetModel(identifierName).GetTypeInfo(identifierName, Token).Type; + if (lhsType is null) { + base.VisitInvocationExpression(node); + return; + } + + var lhsTypeId = CodeService.GetNameFullyQualifiedWithoutGenerics( + lhsType, lhsType.Name + ); + methodName = memberAccess.Name.Identifier.ValueText; + + if ( + lhsTypeId != Constants.LOGIC_BLOCK_CONTEXT_ID || + methodName != Constants.LOGIC_BLOCK_CONTEXT_OUTPUT + ) { + base.VisitInvocationExpression(node); + return; + } + + var args = node.ArgumentList.Arguments; + + if (args.Count != 1) { + base.VisitInvocationExpression(node); + return; + } + + var rhs = node.ArgumentList.Arguments[0].Expression; + var rhsType = GetModel(rhs).GetTypeInfo(rhs, Token).Type; + + if (rhsType is null) { + base.VisitInvocationExpression(node); + return; + } + + var rhsTypeId = CodeService.GetNameFullyQualifiedWithoutGenerics( + rhsType, rhsType.Name + ); + + AddOutput(rhsTypeId); + return; } - var id = memberAccess.Expression; - if (id is not IdentifierNameSyntax identifierName) { return; } + if (node.Expression is not GenericNameSyntax genericName) { + base.VisitInvocationExpression(node); + return; + } - var lhsType = - GetModel(identifierName).GetTypeInfo(identifierName, Token).Type; - if (lhsType is null) { return; } + // void log(string message) => LogicBlocksGenerator.Log.Print(message); - var lhsTypeId = CodeService.GetNameFullyQualifiedWithoutGenerics( - lhsType, lhsType.Name - ); - var methodName = memberAccess.Name.Identifier.ValueText; + methodName = genericName.Identifier.ValueText; var pushedContext = false; - if (methodName == Constants.LOGIC_BLOCK_CONTEXT_ON_ENTER) { + if (methodName == Constants.LOGIC_BLOCK_STATE_LOGIC_ON_ENTER) { _outputContexts.Push(OutputContexts.OnEnter); pushedContext = true; } - else if (methodName == Constants.LOGIC_BLOCK_CONTEXT_ON_EXIT) { + else if (methodName == Constants.LOGIC_BLOCK_STATE_LOGIC_ON_EXIT) { _outputContexts.Push(OutputContexts.OnExit); pushedContext = true; } @@ -72,32 +117,6 @@ InvocationExpressionSyntax node if (pushedContext) { _outputContexts.Pop(); } - - if ( - lhsTypeId != Constants.LOGIC_BLOCK_CONTEXT_ID || - methodName != Constants.LOGIC_BLOCK_CONTEXT_OUTPUT - ) { - return; - } - - var args = node.ArgumentList.Arguments; - - if (args.Count != 1) { - return; - } - - var rhs = node.ArgumentList.Arguments[0].Expression; - var rhsType = GetModel(rhs).GetTypeInfo(rhs, Token).Type; - - if (rhsType is null) { - return; - } - - var rhsTypeId = CodeService.GetNameFullyQualifiedWithoutGenerics( - rhsType, rhsType.Name - ); - - AddOutput(rhsTypeId); } public override void VisitClassDeclaration(ClassDeclarationSyntax node) { } diff --git a/Chickensoft.LogicBlocks.Generator/src/ReturnTypeVisitor.cs b/Chickensoft.LogicBlocks.Generator/src/ReturnTypeVisitor.cs index 6958e41..1e6e61f 100644 --- a/Chickensoft.LogicBlocks.Generator/src/ReturnTypeVisitor.cs +++ b/Chickensoft.LogicBlocks.Generator/src/ReturnTypeVisitor.cs @@ -49,7 +49,21 @@ private void AddExpressionToReturnTypes(ExpressionSyntax? expression) { if (expression is not ExpressionSyntax expressionSyntax) { return; } + var type = GetModel(expression).GetTypeInfo(expression, Token).Type; + + if (expression is ConditionalExpressionSyntax conditional) { + AddExpressionToReturnTypes(conditional.WhenTrue); + AddExpressionToReturnTypes(conditional.WhenFalse); + return; + } + + if (expression is BinaryExpressionSyntax binary) { + AddExpressionToReturnTypes(binary.Left); + AddExpressionToReturnTypes(binary.Right); + return; + } + if (type is not ITypeSymbol typeSymbol) { return; } diff --git a/Chickensoft.LogicBlocks.Generator/src/common/models/Models.cs b/Chickensoft.LogicBlocks.Generator/src/common/models/Models.cs index 5b8076b..c2a8e92 100644 --- a/Chickensoft.LogicBlocks.Generator/src/common/models/Models.cs +++ b/Chickensoft.LogicBlocks.Generator/src/common/models/Models.cs @@ -71,7 +71,7 @@ public sealed record LogicBlockImplementation( string FilePath, string Id, string Name, - string? InitialStateId, + ImmutableHashSet InitialStateIds, LogicBlockGraph Graph, ImmutableDictionary Inputs, ImmutableDictionary Outputs, diff --git a/Chickensoft.LogicBlocks.Generator/src/common/utils/Constants.cs b/Chickensoft.LogicBlocks.Generator/src/common/utils/Constants.cs index dd640f3..5cde8d4 100644 --- a/Chickensoft.LogicBlocks.Generator/src/common/utils/Constants.cs +++ b/Chickensoft.LogicBlocks.Generator/src/common/utils/Constants.cs @@ -10,8 +10,8 @@ public class Constants { public const string LOGIC_BLOCK_GET_INITIAL_STATE = "GetInitialState"; public const string LOGIC_BLOCK_CONTEXT_ID = "global::Chickensoft.LogicBlocks.Logic.Context"; public const string LOGIC_BLOCK_CONTEXT_OUTPUT = "Output"; - public const string LOGIC_BLOCK_CONTEXT_ON_ENTER = "OnEnter"; - public const string LOGIC_BLOCK_CONTEXT_ON_EXIT = "OnExit"; + public const string LOGIC_BLOCK_STATE_LOGIC_ON_ENTER = "OnEnter"; + public const string LOGIC_BLOCK_STATE_LOGIC_ON_EXIT = "OnExit"; public const string LOGIC_BLOCK_CLASS_ID = "global::Chickensoft.LogicBlocks.Logic"; public const string LOGIC_BLOCK_INPUT_INTERFACE_ID = "global::Chickensoft.LogicBlocks.LogicBlock.IGet"; public const string LOGIC_BLOCK_INPUT_ASYNC_INTERFACE_ID = "global::Chickensoft.LogicBlocks.LogicBlockAsync.IGet"; diff --git a/Chickensoft.LogicBlocks.Tests/Chickensoft.LogicBlocks.Tests.csproj b/Chickensoft.LogicBlocks.Tests/Chickensoft.LogicBlocks.Tests.csproj index f120070..49ffbe0 100644 --- a/Chickensoft.LogicBlocks.Tests/Chickensoft.LogicBlocks.Tests.csproj +++ b/Chickensoft.LogicBlocks.Tests/Chickensoft.LogicBlocks.Tests.csproj @@ -30,6 +30,8 @@ + + diff --git a/Chickensoft.LogicBlocks.Tests/coverage.sh b/Chickensoft.LogicBlocks.Tests/coverage.sh index b7a7743..3819fb3 100755 --- a/Chickensoft.LogicBlocks.Tests/coverage.sh +++ b/Chickensoft.LogicBlocks.Tests/coverage.sh @@ -10,11 +10,7 @@ dotnet test \ reportgenerator \ -reports:"./coverage/coverage.opencover.xml" \ -targetdir:"./coverage/report" \ - -reporttypes:Html - -reportgenerator \ - -reports:"./coverage/coverage.opencover.xml" \ - -targetdir:"./coverage/report" \ + "-assemblyfilters:-*Chickensoft.LogicBlocks.Generator*" \ -reporttypes:"Html;Badges" # Copy badges into their own folder. The badges folder should be included in diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlock.cs b/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlock.cs index be4ae0f..b706c0b 100644 --- a/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlock.cs +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlock.cs @@ -1,83 +1,99 @@ namespace Chickensoft.LogicBlocks.Tests.Fixtures; using System.Collections.Generic; +using Chickensoft.LogicBlocks.Generator; +[StateMachine] public partial class FakeLogicBlock { - public interface IInput { - public record struct InputOne(int Value1, int Value2) : IInput; - public record struct InputTwo(string Value1, string Value2) - : IInput; - public record struct InputError() : IInput; - public record struct InputUnknown() : IInput; - public record struct GetString() : IInput; - public record struct NoNewState() : IInput; - public record struct InputCallback( + public abstract record Input { + public record InputOne(int Value1, int Value2) : Input; + public record InputTwo(string Value1, string Value2) + : Input; + public record InputError() : Input; + public record InputUnknown() : Input; + public record GetString() : Input; + public record NoNewState() : Input; + public record SelfInput(Input Input) : Input; + public record InputCallback( Action Callback, Func Next - ) : IInput; - public record struct Custom(Func Next) : IInput; + ) : Input; + public record Custom(Func Next) : Input; } public abstract record State(Context Context) : StateLogic(Context), - IGet, - IGet, - IGet, - IGet, - IGet, - IGet, - IGet { - public State On(IInput.InputOne input) { - Context.Output(new IOutput.OutputOne(1)); + IGet, + IGet, + IGet, + IGet, + IGet, + IGet, + IGet, + IGet { + public State On(Input.InputOne input) { + Context.Output(new Output.OutputOne(1)); return new StateA(Context, input.Value1, input.Value2); } - public State On(IInput.InputTwo input) { - Context.Output(new IOutput.OutputTwo("2")); + public State On(Input.InputTwo input) { + Context.Output(new Output.OutputTwo("2")); return new StateB(Context, input.Value1, input.Value2); } - public State On(IInput.InputError input) + public State On(Input.InputError input) => throw new InvalidOperationException(); - public State On(IInput.NoNewState input) { - Context.Output(new IOutput.OutputOne(1)); + public State On(Input.NoNewState input) { + Context.Output(new Output.OutputOne(1)); return this; } - public State On(IInput.InputCallback input) { + public State On(Input.InputCallback input) { input.Callback(); return input.Next(Context); } - public State On(IInput.Custom input) => input.Next(Context); + public State On(Input.Custom input) => input.Next(Context); - public State On(IInput.GetString input) => new StateC( + public State On(Input.GetString input) => new StateC( Context, Context.Get() ); + public State On(Input.SelfInput input) => Context.Input(input.Input); + public record StateA(Context Context, int Value1, int Value2) : State(Context); public record StateB(Context Context, string Value1, string Value2) : State(Context); public record StateC(Context Context, string Value) : State(Context); + + public record NothingState(Context Context) : State(Context); + public record Custom : State { public Custom(Context context, Action setupCallback) : base(context) { setupCallback(context); } } + + public record OnEnterState : State { + public OnEnterState(Context context, Action onEnter) : + base(context) { + OnEnter(onEnter); + } + } } - public interface IOutput { - public record struct OutputOne(int Value) : IOutput; - public record struct OutputTwo(string Value) : IOutput; + public abstract record Output { + public record OutputOne(int Value) : Output; + public record OutputTwo(string Value) : Output; } } public partial class FakeLogicBlock : LogicBlock< - FakeLogicBlock.IInput, FakeLogicBlock.State, FakeLogicBlock.IOutput + FakeLogicBlock.Input, FakeLogicBlock.State, FakeLogicBlock.Output > { public Func? InitialState { get; init; } diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlock.g.puml b/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlock.g.puml new file mode 100644 index 0000000..6aefa42 --- /dev/null +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlock.g.puml @@ -0,0 +1,24 @@ +@startuml FakeLogicBlock +state "FakeLogicBlock State" as Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State { + state "StateA" as Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State_StateA + state "StateB" as Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State_StateB + state "StateC" as Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State_StateC + state "NothingState" as Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State_NothingState + state "Custom" as Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State_Custom + state "OnEnterState" as Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State_OnEnterState + Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State : OnInputOne → OutputOne + Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State : OnInputTwo → OutputTwo + Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State : OnNoNewState → OutputOne +} + +Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State --> Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State : Custom +Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State --> Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State : InputCallback +Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State --> Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State : NoNewState +Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State --> Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State : SelfInput +Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State --> Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State_StateA : InputOne +Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State --> Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State_StateB : InputTwo +Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State --> Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State_StateC : GetString + +[*] --> Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State +[*] --> Chickensoft_LogicBlocks_Tests_Fixtures_FakeLogicBlock_State_StateA +@enduml \ No newline at end of file diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlockAsync.cs b/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlockAsync.cs index 1c45f8b..e0b5b21 100644 --- a/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlockAsync.cs +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlockAsync.cs @@ -5,58 +5,67 @@ namespace Chickensoft.LogicBlocks.Tests.Fixtures; #pragma warning disable CS1998 public partial class FakeLogicBlockAsync { - public interface IInput { - public record struct InputOne(int Value1, int Value2) : IInput; - public record struct InputTwo(string Value1, string Value2) - : IInput; - public record struct InputError() : IInput; - public record struct InputUnknown() : IInput; - public record struct GetString() : IInput; - public record struct NoNewState() : IInput; - public record struct InputCallback( + public abstract record Input { + public record InputOne(int Value1, int Value2) : Input; + public record InputTwo(string Value1, string Value2) + : Input; + public record InputError() : Input; + public record InputUnknown() : Input; + public record GetString() : Input; + public record NoNewState() : Input; + public record SelfInput(Input Input) : Input; + public record InputCallback( Action Callback, Func Next - ) : IInput; - public record struct Custom(Func Next) : IInput; + ) : Input; + public record Custom(Func Next) : Input; } public abstract record State(Context Context) : StateLogic(Context), - IGet, - IGet, - IGet, - IGet, - IGet, - IGet, - IGet { - public async Task On(IInput.InputOne input) { - Context.Output(new IOutput.OutputOne(1)); + IGet, + IGet, + IGet, + IGet, + IGet, + IGet, + IGet, + IGet { + public async Task On(Input.InputOne input) { + Context.Output(new Output.OutputOne(1)); return new StateA(Context, input.Value1, input.Value2); } - public async Task On(IInput.InputTwo input) { - Context.Output(new IOutput.OutputTwo("2")); + public async Task On(Input.InputTwo input) { + Context.Output(new Output.OutputTwo("2")); return new StateB(Context, input.Value1, input.Value2); } - public async Task On(IInput.InputError input) + public async Task On(Input.InputError input) => throw new InvalidOperationException(); - public async Task On(IInput.NoNewState input) { - Context.Output(new IOutput.OutputOne(1)); + public async Task On(Input.NoNewState input) { + Context.Output(new Output.OutputOne(1)); return this; } - public async Task On(IInput.InputCallback input) { + public async Task On(Input.InputCallback input) { input.Callback(); return input.Next(Context); } - public async Task On(IInput.Custom input) => input.Next(Context); + public async Task On(Input.Custom input) => input.Next(Context); - public async Task On(IInput.GetString input) => new StateC( + public async Task On(Input.GetString input) => new StateC( Context, Context.Get() ); + public async Task On(Input.SelfInput input) { + // Can't await input in an async logic block — would deadlock. + Context.Input(input.Input); + // Return our current state in the meantime. + return this; + } + public record StateA(Context Context, int Value1, int Value2) : State(Context); public record StateB(Context Context, string Value1, string Value2) : @@ -69,17 +78,24 @@ public Custom(Context context, Action setupCallback) : setupCallback(context); } } + + public record OnEnterState : State { + public OnEnterState(Context context, Func onEnter) : + base(context) { + OnEnter(onEnter); + } + } } - public interface IOutput { - public record struct OutputOne(int Value) : IOutput; - public record struct OutputTwo(string Value) : IOutput; + public abstract record Output { + public record OutputOne(int Value) : Output; + public record OutputTwo(string Value) : Output; } } public partial class FakeLogicBlockAsync : LogicBlockAsync< - FakeLogicBlockAsync.IInput, FakeLogicBlockAsync.State, FakeLogicBlockAsync.IOutput + FakeLogicBlockAsync.Input, FakeLogicBlockAsync.State, FakeLogicBlockAsync.Output > { public Func? InitialState { get; init; } diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachine.cs b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachine.cs index c2a0c22..6499f1d 100644 --- a/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachine.cs +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachine.cs @@ -1,10 +1,13 @@ namespace Chickensoft.LogicBlocks.Tests.Fixtures; +using Chickensoft.LogicBlocks.Generator; + public enum SecondaryState { Blooped, Bopped } +[StateMachine] public partial class TestMachine : LogicBlock { public abstract record Input { @@ -23,10 +26,10 @@ public State On(Input.Activate input) => public abstract record Activated : State, IGet { public Activated(Context context) : base(context) { - context.OnEnter( + OnEnter( (previous) => context.Output(new Output.Activated()) ); - context.OnExit( + OnExit( (next) => context.Output(new Output.ActivatedCleanUp()) ); } @@ -35,10 +38,10 @@ public Activated(Context context) : base(context) { public record Blooped : Activated { public Blooped(Context context) : base(context) { - context.OnEnter( + OnEnter( (previous) => context.Output(new Output.Blooped()) ); - context.OnExit( + OnExit( (next) => context.Output(new Output.BloopedCleanUp()) ); } @@ -46,10 +49,10 @@ public Blooped(Context context) : base(context) { public record Bopped : Activated { public Bopped(Context context) : base(context) { - context.OnEnter( + OnEnter( (previous) => context.Output(new Output.Bopped()) ); - context.OnExit( + OnExit( (next) => context.Output(new Output.BoppedCleanUp()) ); } @@ -58,10 +61,10 @@ public Bopped(Context context) : base(context) { public record Deactivated : State { public Deactivated(Context context) : base(context) { - context.OnEnter( + OnEnter( (previous) => context.Output(new Output.Deactivated()) ); - context.OnExit( + OnExit( (next) => context.Output(new Output.DeactivatedCleanUp()) ); } diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachine.g.puml b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachine.g.puml new file mode 100644 index 0000000..3eec110 --- /dev/null +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachine.g.puml @@ -0,0 +1,24 @@ +@startuml TestMachine +state "TestMachine State" as Chickensoft_LogicBlocks_Tests_Fixtures_TestMachine_State { + state "Activated" as Chickensoft_LogicBlocks_Tests_Fixtures_TestMachine_State_Activated { + state "Blooped" as Chickensoft_LogicBlocks_Tests_Fixtures_TestMachine_State_Activated_Blooped { + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachine_State_Activated_Blooped : OnEnter → Blooped + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachine_State_Activated_Blooped : OnExit → BloopedCleanUp + } + state "Bopped" as Chickensoft_LogicBlocks_Tests_Fixtures_TestMachine_State_Activated_Bopped { + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachine_State_Activated_Bopped : OnEnter → Bopped + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachine_State_Activated_Bopped : OnExit → BoppedCleanUp + } + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachine_State_Activated : OnEnter → Activated + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachine_State_Activated : OnExit → ActivatedCleanUp + } + state "Deactivated" as Chickensoft_LogicBlocks_Tests_Fixtures_TestMachine_State_Deactivated { + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachine_State_Deactivated : OnEnter → Deactivated + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachine_State_Deactivated : OnExit → DeactivatedCleanUp + } +} + +Chickensoft_LogicBlocks_Tests_Fixtures_TestMachine_State_Activated --> Chickensoft_LogicBlocks_Tests_Fixtures_TestMachine_State_Deactivated : Deactivate + +[*] --> Chickensoft_LogicBlocks_Tests_Fixtures_TestMachine_State_Deactivated +@enduml \ No newline at end of file diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineAsync.cs b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineAsync.cs index 977adf4..85ff828 100644 --- a/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineAsync.cs +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineAsync.cs @@ -23,13 +23,13 @@ public async Task On(Input.Activate input) { public abstract record Activated : State, IGet { public Activated(Context context) : base(context) { - context.OnEnter( + OnEnter( async (previous) => { await Task.Delay(10); context.Output(new Output.Activated()); } ); - context.OnExit( + OnExit( async (next) => { await Task.Delay(20); context.Output(new Output.ActivatedCleanUp()); @@ -42,13 +42,13 @@ public async Task On(Input.Deactivate input) => public record Blooped : Activated { public Blooped(Context context) : base(context) { - context.OnEnter( + OnEnter( async (previous) => { await Task.Delay(10); context.Output(new Output.Blooped()); } ); - context.OnExit( + OnExit( async (next) => { await Task.Delay(15); context.Output(new Output.BloopedCleanUp()); @@ -59,13 +59,13 @@ public Blooped(Context context) : base(context) { public record Bopped : Activated { public Bopped(Context context) : base(context) { - context.OnEnter( + OnEnter( async (previous) => { await Task.Delay(10); context.Output(new Output.Bopped()); } ); - context.OnExit( + OnExit( async (next) => { await Task.Delay(20); context.Output(new Output.BoppedCleanUp()); @@ -77,13 +77,13 @@ public Bopped(Context context) : base(context) { public record Deactivated : State { public Deactivated(Context context) : base(context) { - context.OnEnter( + OnEnter( async (previous) => { await Task.Delay(20); context.Output(new Output.Deactivated()); } ); - context.OnExit( + OnExit( async (next) => { await Task.Delay(20); context.Output(new Output.DeactivatedCleanUp()); diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusable.cs b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusable.cs new file mode 100644 index 0000000..be150db --- /dev/null +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusable.cs @@ -0,0 +1,94 @@ +namespace Chickensoft.LogicBlocks.Tests.Fixtures; + +using Chickensoft.LogicBlocks.Generator; + +[StateMachine] +public partial class TestMachineReusable : + LogicBlock< + TestMachineReusable.Input, + TestMachineReusable.State, + TestMachineReusable.Output + > { + public abstract record Input { + public record Activate(SecondaryState Secondary) : Input; + public record Deactivate() : Input; + } + + public abstract record State(Context Context) : StateLogic(Context), + IGet { + public State On(Input.Activate input) => + input.Secondary switch { + SecondaryState.Blooped => Context.Get(), + SecondaryState.Bopped => Context.Get(), + _ => throw new ArgumentException("Unrecognized secondary state.") + }; + + public abstract record Activated : State, IGet { + public Activated(Context context) : base(context) { + OnEnter( + (previous) => context.Output(new Output.Activated()) + ); + OnExit( + (next) => context.Output(new Output.ActivatedCleanUp()) + ); + } + + public State On(Input.Deactivate input) => Context.Get(); + + public record Blooped : Activated { + public Blooped(Context context) : base(context) { + OnEnter( + (previous) => context.Output(new Output.Blooped()) + ); + OnExit( + (next) => context.Output(new Output.BloopedCleanUp()) + ); + } + } + + public record Bopped : Activated { + public Bopped(Context context) : base(context) { + OnEnter( + (previous) => context.Output(new Output.Bopped()) + ); + OnExit( + (next) => context.Output(new Output.BoppedCleanUp()) + ); + } + } + } + + public record Deactivated : State { + public Deactivated(Context context) : base(context) { + OnEnter( + (previous) => context.Output(new Output.Deactivated()) + ); + OnExit( + (next) => context.Output(new Output.DeactivatedCleanUp()) + ); + } + } + } + + public abstract record Output { + public record Activated() : Output; + public record ActivatedCleanUp() : Output; + public record Deactivated() : Output; + public record DeactivatedCleanUp() : Output; + public record Blooped() : Output; + public record BloopedCleanUp() : Output; + public record Bopped() : Output; + public record BoppedCleanUp() : Output; + } + + public TestMachineReusable() { + Set(new State.Activated.Blooped(Context)); + Set(new State.Activated.Bopped(Context)); + } + + public override State GetInitialState(Context context) { + var deactivated = new State.Deactivated(Context); + Set(deactivated); + return deactivated; + } +} diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusable.g.puml b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusable.g.puml new file mode 100644 index 0000000..26a543f --- /dev/null +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusable.g.puml @@ -0,0 +1,24 @@ +@startuml TestMachineReusable +state "TestMachineReusable State" as Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusable_State { + state "Activated" as Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusable_State_Activated { + state "Blooped" as Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusable_State_Activated_Blooped { + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusable_State_Activated_Blooped : OnEnter → Blooped + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusable_State_Activated_Blooped : OnExit → BloopedCleanUp + } + state "Bopped" as Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusable_State_Activated_Bopped { + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusable_State_Activated_Bopped : OnEnter → Bopped + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusable_State_Activated_Bopped : OnExit → BoppedCleanUp + } + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusable_State_Activated : OnEnter → Activated + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusable_State_Activated : OnExit → ActivatedCleanUp + } + state "Deactivated" as Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusable_State_Deactivated { + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusable_State_Deactivated : OnEnter → Deactivated + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusable_State_Deactivated : OnExit → DeactivatedCleanUp + } +} + +Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusable_State_Activated --> Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusable_State_Deactivated : Deactivate + +[*] --> Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusable_State_Deactivated +@enduml \ No newline at end of file diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusableAsync.cs b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusableAsync.cs new file mode 100644 index 0000000..82df019 --- /dev/null +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusableAsync.cs @@ -0,0 +1,125 @@ +namespace Chickensoft.LogicBlocks.Tests.Fixtures; +using Chickensoft.LogicBlocks.Generator; + +#pragma warning disable CS1998 + +[StateMachine] +public partial class TestMachineReusableAsync : + LogicBlockAsync< + TestMachineReusableAsync.Input, + TestMachineReusableAsync.State, + TestMachineReusableAsync.Output +> { + public abstract record Input { + public record Activate(SecondaryState Secondary) : Input; + public record Deactivate() : Input; + } + + public abstract record State(Context Context) : StateLogic(Context), + IGet { + public async Task On(Input.Activate input) { + await Task.Delay(5); + + return input.Secondary switch { + SecondaryState.Blooped => Context.Get(), + SecondaryState.Bopped => Context.Get(), + _ => throw new ArgumentException("Unrecognized secondary state.") + }; + } + + public abstract record Activated : State, IGet { + public Activated(Context context) : base(context) { + OnEnter( + async (previous) => { + await Task.Delay(10); + context.Output(new Output.Activated()); + } + ); + OnExit( + async (next) => { + await Task.Delay(20); + context.Output(new Output.ActivatedCleanUp()); + } + ); + } + + public async Task On(Input.Deactivate input) => + Context.Get(); + + public record Blooped : Activated { + public Blooped(Context context) : base(context) { + OnEnter( + async (previous) => { + await Task.Delay(10); + context.Output(new Output.Blooped()); + } + ); + OnExit( + async (next) => { + await Task.Delay(15); + context.Output(new Output.BloopedCleanUp()); + } + ); + } + } + + public record Bopped : Activated { + public Bopped(Context context) : base(context) { + OnEnter( + async (previous) => { + await Task.Delay(10); + context.Output(new Output.Bopped()); + } + ); + OnExit( + async (next) => { + await Task.Delay(20); + context.Output(new Output.BoppedCleanUp()); + } + ); + } + } + } + + public record Deactivated : State { + public Deactivated(Context context) : base(context) { + OnEnter( + async (previous) => { + await Task.Delay(20); + context.Output(new Output.Deactivated()); + } + ); + OnExit( + async (next) => { + await Task.Delay(20); + context.Output(new Output.DeactivatedCleanUp()); + } + ); + } + } + } + + public abstract record Output { + public record Activated() : Output; + public record ActivatedCleanUp() : Output; + public record Deactivated() : Output; + public record DeactivatedCleanUp() : Output; + public record Blooped() : Output; + public record BloopedCleanUp() : Output; + public record Bopped() : Output; + public record BoppedCleanUp() : Output; + } + + public TestMachineReusableAsync() { + Set(new State.Activated.Blooped(Context)); + Set(new State.Activated.Bopped(Context)); + } + + public override State GetInitialState(Context context) { + var deactivated = new State.Deactivated(Context); + Set(deactivated); + return deactivated; + } +} + +#pragma warning restore CS1998 diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusableAsync.g.puml b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusableAsync.g.puml new file mode 100644 index 0000000..e1afd62 --- /dev/null +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusableAsync.g.puml @@ -0,0 +1,24 @@ +@startuml TestMachineReusableAsync +state "TestMachineReusableAsync State" as Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusableAsync_State { + state "Activated" as Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusableAsync_State_Activated { + state "Blooped" as Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusableAsync_State_Activated_Blooped { + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusableAsync_State_Activated_Blooped : OnEnter → Blooped + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusableAsync_State_Activated_Blooped : OnExit → BloopedCleanUp + } + state "Bopped" as Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusableAsync_State_Activated_Bopped { + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusableAsync_State_Activated_Bopped : OnEnter → Bopped + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusableAsync_State_Activated_Bopped : OnExit → BoppedCleanUp + } + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusableAsync_State_Activated : OnEnter → Activated + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusableAsync_State_Activated : OnExit → ActivatedCleanUp + } + state "Deactivated" as Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusableAsync_State_Deactivated { + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusableAsync_State_Deactivated : OnEnter → Deactivated + Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusableAsync_State_Deactivated : OnExit → DeactivatedCleanUp + } +} + +Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusableAsync_State_Activated --> Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusableAsync_State_Deactivated : Deactivate + +[*] --> Chickensoft_LogicBlocks_Tests_Fixtures_TestMachineReusableAsync_State_Deactivated +@enduml \ No newline at end of file diff --git a/Chickensoft.LogicBlocks.Tests/test/src/Logic.StateLogicTest.cs b/Chickensoft.LogicBlocks.Tests/test/src/Logic.StateLogicTest.cs new file mode 100644 index 0000000..6cf7125 --- /dev/null +++ b/Chickensoft.LogicBlocks.Tests/test/src/Logic.StateLogicTest.cs @@ -0,0 +1,14 @@ +namespace Chickensoft.LogicBlocks.Tests; + +using Chickensoft.LogicBlocks.Tests.Fixtures; +using Shouldly; +using Xunit; + +public class LogicStateLogicTest { + [Fact] + public void Initializes() { + var logic = new TestMachine(); + var stateLogic = new TestMachine.State.Deactivated(logic.Context); + stateLogic.GetHashCode().ShouldBeOfType(); + } +} diff --git a/Chickensoft.LogicBlocks.Tests/test/src/LogicBlock.BindingTest.cs b/Chickensoft.LogicBlocks.Tests/test/src/LogicBlock.BindingTest.cs index 3eac19c..e7b3abc 100644 --- a/Chickensoft.LogicBlocks.Tests/test/src/LogicBlock.BindingTest.cs +++ b/Chickensoft.LogicBlocks.Tests/test/src/LogicBlock.BindingTest.cs @@ -31,7 +31,7 @@ public void UpdatesCorrectly() { callA1.ShouldBe(0); callA2.ShouldBe(0); - block.Input(new FakeLogicBlock.IInput.InputOne(a1, a2)); + block.Input(new FakeLogicBlock.Input.InputOne(a1, a2)); callA1.ShouldBe(1); callA2.ShouldBe(1); @@ -39,14 +39,14 @@ public void UpdatesCorrectly() { // Make sure the same values don't trigger the actions again a1 = 5; - block.Input(new FakeLogicBlock.IInput.InputOne(a1, a2)); + block.Input(new FakeLogicBlock.Input.InputOne(a1, a2)); callA1.ShouldBe(2); callA2.ShouldBe(1); // Make sure unrelated events don't trigger the actions - block.Input(new FakeLogicBlock.IInput.InputTwo("a", "b")); + block.Input(new FakeLogicBlock.Input.InputTwo("a", "b")); callA1.ShouldBe(2); callA2.ShouldBe(1); @@ -54,7 +54,7 @@ public void UpdatesCorrectly() { // Make sure that previous unrelated states cause actions for new state // to be called - block.Input(new FakeLogicBlock.IInput.InputOne(a1, a2)); + block.Input(new FakeLogicBlock.Input.InputOne(a1, a2)); callA1.ShouldBe(3); callA2.ShouldBe(2); @@ -68,20 +68,20 @@ public void HandlesEffects() { var callEffect1 = 0; var callEffect2 = 0; - glue.Handle( + glue.Handle( (effect) => { callEffect1++; effect.Value.ShouldBe(1); } - ).Handle( + ).Handle( (effect) => { callEffect2++; effect.Value.ShouldBe("2"); } ); // Effects should get handled each time, regardless of if they are // identical to the previous one. - block.Input(new FakeLogicBlock.IInput.InputOne(1, 2)); - block.Input(new FakeLogicBlock.IInput.InputOne(1, 2)); + block.Input(new FakeLogicBlock.Input.InputOne(1, 2)); + block.Input(new FakeLogicBlock.Input.InputOne(1, 2)); - block.Input(new FakeLogicBlock.IInput.InputTwo("a", "b")); - block.Input(new FakeLogicBlock.IInput.InputTwo("a", "b")); + block.Input(new FakeLogicBlock.Input.InputTwo("a", "b")); + block.Input(new FakeLogicBlock.Input.InputTwo("a", "b")); callEffect1.ShouldBe(2); callEffect2.ShouldBe(2); @@ -108,31 +108,31 @@ public void CallsSubstateTransitionsOnlyOnce() { block.Value.ShouldBe(block.GetInitialState(context)); // State is StateA initially, so switch to State B - block.Input(new FakeLogicBlock.IInput.InputTwo("a", "b")); + block.Input(new FakeLogicBlock.Input.InputTwo("a", "b")); callStateA.ShouldBe(0); callStateB.ShouldBe(1); block.Value.ShouldBeOfType(); - block.Input(new FakeLogicBlock.IInput.InputTwo("a", "b")); + block.Input(new FakeLogicBlock.Input.InputTwo("a", "b")); callStateA.ShouldBe(0); callStateB.ShouldBe(1); block.Value.ShouldBeOfType(); - block.Input(new FakeLogicBlock.IInput.InputTwo("c", "d")); + block.Input(new FakeLogicBlock.Input.InputTwo("c", "d")); callStateA.ShouldBe(0); callStateB.ShouldBe(1); block.Value.ShouldBeOfType(); - block.Input(new FakeLogicBlock.IInput.InputOne(1, 2)); + block.Input(new FakeLogicBlock.Input.InputOne(1, 2)); callStateA.ShouldBe(1); callStateB.ShouldBe(1); block.Value.ShouldBeOfType(); - block.Input(new FakeLogicBlock.IInput.InputTwo("a", "b")); + block.Input(new FakeLogicBlock.Input.InputTwo("a", "b")); callStateA.ShouldBe(1); callStateB.ShouldBe(2); @@ -153,18 +153,18 @@ public void CleansUpSubscriptions() { to: (value1) => callStateUpdate++ ); - glue.Handle( + glue.Handle( (effect) => callSideEffectHandler++ ); - block.Input(new FakeLogicBlock.IInput.InputOne(4, 5)); + block.Input(new FakeLogicBlock.Input.InputOne(4, 5)); callStateUpdate.ShouldBe(1); callSideEffectHandler.ShouldBe(1); glue.Dispose(); - block.Input(new FakeLogicBlock.IInput.InputOne(5, 6)); + block.Input(new FakeLogicBlock.Input.InputOne(5, 6)); callStateUpdate.ShouldBe(1); callSideEffectHandler.ShouldBe(1); diff --git a/Chickensoft.LogicBlocks.Tests/test/src/LogicBlockAsyncTest.cs b/Chickensoft.LogicBlocks.Tests/test/src/LogicBlockAsyncTest.cs index b97e370..eb27acb 100644 --- a/Chickensoft.LogicBlocks.Tests/test/src/LogicBlockAsyncTest.cs +++ b/Chickensoft.LogicBlocks.Tests/test/src/LogicBlockAsyncTest.cs @@ -64,6 +64,58 @@ await logic.Input( }); } + [Fact] + public async Task CallsEnterAndExitOnStatesInProperOrderForReusedStates() { + var logic = new TestMachineReusableAsync(); + var context = new TestMachineReusableAsync.Context(logic); + + var outputs = new List(); + + void onOutput(object? block, TestMachineReusableAsync.Output output) => + outputs.Add(output); + + logic.OnOutput += onOutput; + + logic.Value.ShouldBeOfType(); + var taskA = logic.Input( + new TestMachineReusableAsync.Input.Activate(SecondaryState.Blooped) + ); + var taskB = logic.Input( + new TestMachineReusableAsync.Input.Deactivate() + ); + taskA.ShouldBeSameAs(taskB); + await logic.Input( + new TestMachineReusableAsync.Input.Activate(SecondaryState.Bopped) + ); + // Repeating previous state should do nothing. + await logic.Input( + new TestMachineReusableAsync.Input.Activate(SecondaryState.Bopped) + ); + await logic.Input( + new TestMachineReusableAsync.Input.Activate(SecondaryState.Blooped) + ); + await logic.Input( + new TestMachineReusableAsync.Input.Deactivate() + ); + + outputs.ShouldBe(new TestMachineReusableAsync.Output[] { + new TestMachineReusableAsync.Output.DeactivatedCleanUp(), + new TestMachineReusableAsync.Output.Activated(), + new TestMachineReusableAsync.Output.Blooped(), + new TestMachineReusableAsync.Output.BloopedCleanUp(), + new TestMachineReusableAsync.Output.ActivatedCleanUp(), + new TestMachineReusableAsync.Output.Deactivated(), + new TestMachineReusableAsync.Output.DeactivatedCleanUp(), + new TestMachineReusableAsync.Output.Activated(), + new TestMachineReusableAsync.Output.Bopped(), + new TestMachineReusableAsync.Output.BoppedCleanUp(), + new TestMachineReusableAsync.Output.Blooped(), + new TestMachineReusableAsync.Output.BloopedCleanUp(), + new TestMachineReusableAsync.Output.ActivatedCleanUp(), + new TestMachineReusableAsync.Output.Deactivated(), + }); + } + [Fact] public async Task InvokesErrorEventFromUpdateHandler() { var block = new FakeLogicBlockAsync(); @@ -75,13 +127,11 @@ public async Task InvokesErrorEventFromUpdateHandler() { block.OnNextError += handler; block.Exceptions.ShouldBeEmpty(); - await block.Input(new FakeLogicBlockAsync.IInput.Custom( - (context) => new FakeLogicBlockAsync.State.Custom( + await block.Input(new FakeLogicBlockAsync.Input.Custom( + (context) => new FakeLogicBlockAsync.State.OnEnterState( context, - (context) => context.OnEnter( - (previous) => - throw new InvalidOperationException("Error from OnEnter") - ) + (previous) => + throw new InvalidOperationException("Error from OnEnter") ) ) ); @@ -96,7 +146,7 @@ await block.Input(new FakeLogicBlockAsync.IInput.Custom( public async Task DoesNothingOnUnhandledInput() { var block = new FakeLogicBlockAsync(); var context = new FakeLogicBlockAsync.Context(block); - await block.Input(new FakeLogicBlockAsync.IInput.InputUnknown()); + await block.Input(new FakeLogicBlockAsync.Input.InputUnknown()); block.Value.ShouldBe(block.GetInitialState(context)); } @@ -111,11 +161,21 @@ public async Task InvokesErrorEvent() { block.OnNextError += handler; block.Exceptions.ShouldBeEmpty(); - await block.Input(new FakeLogicBlockAsync.IInput.InputError()); + await block.Input(new FakeLogicBlockAsync.Input.InputError()); block.Exceptions.ShouldNotBeEmpty(); called.ShouldBe(1); block.OnNextError -= handler; } + + [Fact] + public async Task StateCanAddInputUsingContext() { + var logic = new FakeLogicBlockAsync(); + var input = new FakeLogicBlockAsync.Input.InputOne(5, 6); + + await logic.Input(new FakeLogicBlockAsync.Input.SelfInput(input)); + + logic.Value.ShouldBeOfType(); + } } diff --git a/Chickensoft.LogicBlocks.Tests/test/src/LogicBlockTest.cs b/Chickensoft.LogicBlocks.Tests/test/src/LogicBlockTest.cs index e576627..e09f8b1 100644 --- a/Chickensoft.LogicBlocks.Tests/test/src/LogicBlockTest.cs +++ b/Chickensoft.LogicBlocks.Tests/test/src/LogicBlockTest.cs @@ -22,7 +22,7 @@ public void GetsAndSetsBlackboardData() { // Can't change values once set. Should.Throw(() => block.PublicSet("other")); Should.Throw(() => block.Get()); - block.Input(new FakeLogicBlock.IInput.GetString()); + block.Input(new FakeLogicBlock.Input.GetString()); block.Value.ShouldBe(new FakeLogicBlock.State.StateC(context, "data")); } @@ -31,21 +31,21 @@ public void InvokesInputEvent() { var block = new FakeLogicBlock(); var called = 0; - var input = new FakeLogicBlock.IInput.InputOne(2, 3); + var input = new FakeLogicBlock.Input.InputOne(2, 3); - void handler(object? block, FakeLogicBlock.IInput input) { + void handler(object? block, FakeLogicBlock.Input input) { input.ShouldBe(input); called++; } block.OnInput += handler; - block.Input(new FakeLogicBlock.IInput.InputOne(2, 3)); + block.Input(new FakeLogicBlock.Input.InputOne(2, 3)); called.ShouldBe(1); block.OnInput -= handler; - block.Input(new FakeLogicBlock.IInput.InputOne(2, 3)); + block.Input(new FakeLogicBlock.Input.InputOne(2, 3)); called.ShouldBe(1); } @@ -54,21 +54,21 @@ public void InvokesOutputEvent() { var block = new FakeLogicBlock(); var called = 0; - var output = new FakeLogicBlock.IOutput.OutputOne(2); + var output = new FakeLogicBlock.Output.OutputOne(2); - void handler(object? block, FakeLogicBlock.IOutput output) { + void handler(object? block, FakeLogicBlock.Output output) { output.ShouldBe(output); called++; } block.OnOutput += handler; - block.Input(new FakeLogicBlock.IInput.InputOne(2, 3)); + block.Input(new FakeLogicBlock.Input.InputOne(2, 3)); called.ShouldBe(1); block.OnOutput -= handler; - block.Input(new FakeLogicBlock.IInput.InputOne(2, 3)); + block.Input(new FakeLogicBlock.Input.InputOne(2, 3)); called.ShouldBe(1); } @@ -89,7 +89,7 @@ void handler(object? block, FakeLogicBlock.State state) { called.ShouldBe(1); - block.Input(new FakeLogicBlock.IInput.InputOne(2, 3)); + block.Input(new FakeLogicBlock.Input.InputOne(2, 3)); called.ShouldBe(2); block.OnState -= handler; @@ -112,7 +112,7 @@ void handler(object? block, FakeLogicBlock.State state) { called.ShouldBe(0); - block.Input(new FakeLogicBlock.IInput.InputOne(2, 3)); + block.Input(new FakeLogicBlock.Input.InputOne(2, 3)); called.ShouldBe(1); block.OnNextState -= handler; @@ -129,7 +129,7 @@ public void InvokesErrorEvent() { block.OnNextError += handler; block.Exceptions.ShouldBeEmpty(); - block.Input(new FakeLogicBlock.IInput.InputError()); + block.Input(new FakeLogicBlock.Input.InputError()); block.Exceptions.ShouldNotBeEmpty(); called.ShouldBe(1); @@ -141,7 +141,7 @@ public void InvokesErrorEvent() { public void DoesNothingOnUnhandledInput() { var block = new FakeLogicBlock(); var context = new FakeLogicBlock.Context(block); - block.Input(new FakeLogicBlock.IInput.InputUnknown()); + block.Input(new FakeLogicBlock.Input.InputUnknown()); block.Value.ShouldBe(block.GetInitialState(context)); } @@ -196,15 +196,66 @@ void onOutput(object? block, TestMachine.Output output) => }); } + [Fact] + public void CallsEnterAndExitOnStatesInProperOrderForReusedStates() { + var logic = new TestMachineReusable(); + var context = new TestMachineReusable.Context(logic); + + var outputs = new List(); + + void onOutput(object? block, TestMachineReusable.Output output) => + outputs.Add(output); + + logic.OnOutput += onOutput; + + logic.Value.ShouldBeOfType(); + logic.Input( + new TestMachineReusable.Input.Activate(SecondaryState.Blooped) + ); + logic.Input( + new TestMachineReusable.Input.Deactivate() + ); + logic.Input( + new TestMachineReusable.Input.Activate(SecondaryState.Bopped) + ); + // Repeating previous state should do nothing. + logic.Input( + new TestMachineReusable.Input.Activate(SecondaryState.Bopped) + ); + logic.Input( + new TestMachineReusable.Input.Activate(SecondaryState.Blooped) + ); + logic.Input( + new TestMachineReusable.Input.Deactivate() + ); + + outputs.ShouldBe(new TestMachineReusable.Output[] { + new TestMachineReusable.Output.DeactivatedCleanUp(), + new TestMachineReusable.Output.Activated(), + new TestMachineReusable.Output.Blooped(), + new TestMachineReusable.Output.BloopedCleanUp(), + new TestMachineReusable.Output.ActivatedCleanUp(), + new TestMachineReusable.Output.Deactivated(), + new TestMachineReusable.Output.DeactivatedCleanUp(), + new TestMachineReusable.Output.Activated(), + new TestMachineReusable.Output.Bopped(), + new TestMachineReusable.Output.BoppedCleanUp(), + new TestMachineReusable.Output.Blooped(), + new TestMachineReusable.Output.BloopedCleanUp(), + new TestMachineReusable.Output.ActivatedCleanUp(), + new TestMachineReusable.Output.Deactivated(), + }); + } + [Fact] public void ReturnsCurrentValueIfProcessingInputs() { var block = new FakeLogicBlock(); var context = new FakeLogicBlock.Context(block); var called = false; - var value = block.Input(new FakeLogicBlock.IInput.InputCallback( + var value = block.Input(new FakeLogicBlock.Input.InputCallback( () => { // This gets run from the input handler of InputCallback. - var value = block.Input(new FakeLogicBlock.IInput.InputTwo("a", "b")); + var value = block.Input(new FakeLogicBlock.Input.InputTwo("a", "b")); value.ShouldBe(block.GetInitialState(context)); called = true; }, @@ -224,13 +275,11 @@ public void InvokesErrorEventFromUpdateHandler() { block.OnNextError += handler; block.Exceptions.ShouldBeEmpty(); - block.Input(new FakeLogicBlock.IInput.Custom( - (context) => new FakeLogicBlock.State.Custom( + block.Input(new FakeLogicBlock.Input.Custom( + (context) => new FakeLogicBlock.State.OnEnterState( context, - (context) => context.OnEnter( - (previous) => - throw new InvalidOperationException("Error from OnEnter") - ) + (previous) => + throw new InvalidOperationException("Error from OnEnter") ) ) ); @@ -266,7 +315,7 @@ public void OnTransitionCalledWhenTransitioning() { } ); - logic.Input(new FakeLogicBlock.IInput.InputTwo("a", "b")); + logic.Input(new FakeLogicBlock.Input.InputTwo("a", "b")); Should.Throw( () => logic.PublicOnTransition< @@ -276,4 +325,14 @@ public void OnTransitionCalledWhenTransitioning() { called.ShouldBeTrue(); } + + [Fact] + public void StateCanAddInputUsingContext() { + var logic = new FakeLogicBlock(); + var input = new FakeLogicBlock.Input.InputOne(5, 6); + + logic.Input(new FakeLogicBlock.Input.SelfInput(input)); + + logic.Value.ShouldBeOfType(); + } } diff --git a/Chickensoft.LogicBlocks/Chickensoft.LogicBlocks.csproj b/Chickensoft.LogicBlocks/Chickensoft.LogicBlocks.csproj index d9e8539..593d79e 100644 --- a/Chickensoft.LogicBlocks/Chickensoft.LogicBlocks.csproj +++ b/Chickensoft.LogicBlocks/Chickensoft.LogicBlocks.csproj @@ -13,7 +13,7 @@ portable LogicBlocks - 1.0.0 + 2.0.0-beta.1 State management in a box. © 2023 Chickensoft Chickensoft diff --git a/Chickensoft.LogicBlocks/src/Logic.Context.cs b/Chickensoft.LogicBlocks/src/Logic.Context.cs index c8a8d8e..4934131 100644 --- a/Chickensoft.LogicBlocks/src/Logic.Context.cs +++ b/Chickensoft.LogicBlocks/src/Logic.Context.cs @@ -20,13 +20,24 @@ public Context(Logic< } /// - /// Adds an input value to the logic block's internal input queue. + /// Adds an input value to the logic block's internal input queue and + /// returns the current state. + ///
+ /// Async logic blocks cannot await an input they add from a state. If they + /// did, input processing would hang forever in a type of input processing + /// deadlock. + ///
+ /// Instead, represent loading as a state or a property of a state while you + /// add un-awaited inputs from a state. ///
/// Input to process. /// Type of the input. /// Logic block input return value. - public TInputReturn Input(TInputType input) - where TInputType : TInput => Logic.Input(input); + public TState Input(TInputType input) + where TInputType : TInput { + Logic.Input(input); + return Logic.Value; + } /// /// Produces a logic block output value. @@ -34,30 +45,6 @@ public TInputReturn Input(TInputType input) /// Output value. public void Output(TOutput output) => Logic.OutputValue(output); - /// - /// Registers an entrance handler for the logic block state that receives - /// the previous state. - /// - /// Callback to be invoked when the state is - /// entered. - /// Type of state that will invoke an - /// entrance callback. - public void OnEnter(TUpdate handler) - where TStateType : TState => - Logic.AddOnEnterCallback(handler); - - /// - /// Registers an exit handler for the logic block state that receives the - /// next state. - /// - /// Callback to be invoked when the state is - /// exited. - /// Type of state that will invoke an - /// exit callback. - public void OnExit(TUpdate handler) - where TStateType : TState => - Logic.AddOnExitCallback(handler); - /// /// Gets a value from the logic block's blackboard. /// diff --git a/Chickensoft.LogicBlocks/src/Logic.StateLogic.cs b/Chickensoft.LogicBlocks/src/Logic.StateLogic.cs index 71adcab..6359cc9 100644 --- a/Chickensoft.LogicBlocks/src/Logic.StateLogic.cs +++ b/Chickensoft.LogicBlocks/src/Logic.StateLogic.cs @@ -1,5 +1,8 @@ namespace Chickensoft.LogicBlocks; +using System; +using System.Collections.Generic; + public abstract partial class Logic< TInput, TState, TOutput, THandler, TInputReturn, TUpdate > { @@ -12,6 +15,29 @@ public interface IStateLogic { Context Context { get; } } + internal class StateLogicState { + /// + /// Callbacks to be invoked when the state is entered. + /// + internal Queue EnterCallbacks { get; } = new(); + + /// + /// Callbacks to be invoked when the state is exited. + /// + internal Stack ExitCallbacks { get; } = new(); + + // We don't want state logic states to be compared, so we make them + // always equal to whatever other state logic state they are compared to. + // This prevents issues where two seemingly equivalent states are not + // deemed equivalent because their callbacks are different. + public override bool Equals(object obj) => true; + + public override int GetHashCode() => HashCode.Combine( + EnterCallbacks, + ExitCallbacks + ); + } + /// /// Logic block base state record. If you are using records for your logic /// block states, you may inherit from this record rather instead of @@ -22,12 +48,51 @@ public abstract record StateLogic : IStateLogic { /// Logic block context. public Context Context { get; } + internal StateLogicState InternalState { get; } + /// /// Creates a new instance of the logic block base state record. /// /// Logic block context. public StateLogic(Context context) { Context = context; + InternalState = new(); } + + /// + /// Adds a callback that will be invoked when the state is entered. The + /// callback will receive the previous state as an argument. + ///
+ /// Each class in an inheritance hierarchy can register callbacks and they + /// will be invoked in the order they were registered, base class to most + /// derived class. This ordering matches the order in which entrance + /// callbacks should be invoked in a statechart. + ///
+ /// Type of the state that would be entered. + /// + /// Callback to be invoked when the state is entered. + /// + public void OnEnter(TUpdate handler) + where TStateType : StateLogic => InternalState.EnterCallbacks.Enqueue( + new(handler, (state) => state is TStateType) + ); + + /// + /// Adds a callback that will be invoked when the state is exited. The + /// callback will receive the next state as an argument. + ///
+ /// Each class in an inheritance hierarchy can register callbacks and they + /// will be invoked in the opposite order they were registered, most + /// derived class to base class. This ordering matches the order in which + /// exit callbacks should be invoked in a statechart. + ///
+ /// Type of the state that would be exited. + /// + /// Callback to be invoked when the state is exited. + /// + public void OnExit(TUpdate handler) + where TStateType : StateLogic => InternalState.ExitCallbacks.Push( + new(handler, (state) => state is TStateType) + ); } } diff --git a/Chickensoft.LogicBlocks/src/Logic.cs b/Chickensoft.LogicBlocks/src/Logic.cs index cf236f7..dc29061 100644 --- a/Chickensoft.LogicBlocks/src/Logic.cs +++ b/Chickensoft.LogicBlocks/src/Logic.cs @@ -2,7 +2,6 @@ namespace Chickensoft.LogicBlocks; using System; using System.Collections.Generic; -using System.Runtime.CompilerServices; using WeakEvent; /// @@ -128,20 +127,6 @@ private readonly Dictionary< private readonly WeakEventSource _stateEventSource = new(); private readonly WeakEventSource _errorEventSource = new(); private readonly WeakEventSource _outputEventSource = new(); - private readonly Queue _enterCallbacksA = new(); - private readonly Queue _enterCallbacksB = new(); - private readonly Stack _exitCallbacksA = new(); - private readonly Stack _exitCallbacksB = new(); - internal Queue EnterCallbacks => _flipped - ? _enterCallbacksB - : _enterCallbacksA; - internal Stack ExitCallbacks => _flipped - ? _exitCallbacksB - : _exitCallbacksA; - private bool _flipped; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal void Flip() => _flipped = !_flipped; /// /// Creates a new LogicBlock. @@ -271,16 +256,6 @@ Transition transitionCallback ); } - internal void AddOnEnterCallback(TUpdate callback) - where TStateType : TState => EnterCallbacks.Enqueue( - new UpdateCallback(callback, (dynamic state) => state is TStateType) - ); - - internal void AddOnExitCallback(TUpdate callback) - where TStateType : TState => ExitCallbacks.Push( - new UpdateCallback(callback, (dynamic state) => state is TStateType) - ); - /// /// /// Gets the input handler for a given input type. This can be overridden by diff --git a/Chickensoft.LogicBlocks/src/LogicBlock.cs b/Chickensoft.LogicBlocks/src/LogicBlock.cs index fb6be18..ea0a529 100644 --- a/Chickensoft.LogicBlocks/src/LogicBlock.cs +++ b/Chickensoft.LogicBlocks/src/LogicBlock.cs @@ -29,6 +29,11 @@ public abstract partial class LogicBlock : Action >.IStateLogic where TOutput : notnull { + /// + /// The context provided to the states of the logic block. + /// + public new Context Context { get; private set; } = default!; + /// /// Whether or not the logic block is processing inputs. /// @@ -37,8 +42,10 @@ public abstract partial class LogicBlock : private bool _isProcessing; /// - public sealed override TState GetInitialState() => - GetInitialState(new Context(this)); + public sealed override TState GetInitialState() { + Context = new(this); + return GetInitialState(Context); + } /// /// Returns the initial state of the logic block. Implementations must @@ -59,55 +66,42 @@ internal override TState Process() { var handler = pendingInput.GetHandler(); var input = pendingInput.Input; - // Save previous enter callbacks in case we can't change states and need - // to restore them. This does it without allocating a new list. - Flip(); - - // Get next state. This triggers the next state to register its - // OnEnter and OnExit callbacks. + // Get next state. var state = handler(input); AnnounceInput(input); if (!CanChangeState(state)) { - // Don't save registered callbacks from the state we couldn't change to. - ExitCallbacks.Clear(); - EnterCallbacks.Clear(); - Flip(); // Restore previous enter/exit callbacks. + // The only time we can't change states is if the new state is + // equivalent to the old state (determined by the default equality + // comparer) continue; } - // Restore previous enter/exit callbacks. - Flip(); - var previous = Value; - // Call previously registered OnExit callbacks. - foreach (var onExit in ExitCallbacks) { - if (onExit.IsType(state)) { - // Not actually leaving this state type. - continue; + // Call OnExit callbacks for StateLogic states. + if (previous is StateLogic previousLogic) { + foreach (var onExit in previousLogic.InternalState.ExitCallbacks) { + if (onExit.IsType(state)) { + // Not actually leaving this state type. + continue; + } + RunSafe(() => onExit.Callback(state)); } - RunSafe(() => onExit.Callback(state)); } - // Now that exit callbacks have run, clear them. - ExitCallbacks.Clear(); - // The previous state already had its enter callbacks run. - EnterCallbacks.Clear(); - SetState(state); - // Use new state's enter callbacks right now. - Flip(); - - // Call newly registered OnEnter callbacks. - foreach (var onEnter in EnterCallbacks) { - if (onEnter.IsType(previous)) { - // Already entered this state type. - continue; + // Call OnEnter callbacks for StateLogic states. + if (state is StateLogic stateLogic) { + foreach (var onEnter in stateLogic.InternalState.EnterCallbacks) { + if (onEnter.IsType(previous)) { + // Already entered this state type. + continue; + } + RunSafe(() => onEnter.Callback(previous)); } - RunSafe(() => onEnter.Callback(previous)); } FinalizeStateChange(state); diff --git a/Chickensoft.LogicBlocks/src/LogicBlockAsync.cs b/Chickensoft.LogicBlocks/src/LogicBlockAsync.cs index f6f1dbb..99fbb25 100644 --- a/Chickensoft.LogicBlocks/src/LogicBlockAsync.cs +++ b/Chickensoft.LogicBlocks/src/LogicBlockAsync.cs @@ -37,6 +37,11 @@ public abstract partial class LogicBlockAsync : Func >.IStateLogic where TOutput : notnull { + /// + /// The context provided to the states of the logic block. + /// + public new Context Context { get; private set; } = default!; + /// /// Whether or not the logic block is processing inputs. /// @@ -52,8 +57,10 @@ public LogicBlockAsync() { } /// - public sealed override TState GetInitialState() => - GetInitialState(new Context(this)); + public sealed override TState GetInitialState() { + Context = new(this); + return GetInitialState(new Context(this)); + } /// /// Returns the initial state of the logic block. Implementations must @@ -80,54 +87,42 @@ private async Task ProcessInputs() { var handler = pendingInput.GetHandler(); var input = pendingInput.Input; - // Save previous enter callbacks in case we can't change states and need - // to restore them. This does it without allocating a new list. - Flip(); - - // Get next state. This triggers the next state to register its - // OnEnter and OnExit callbacks. + // Get next state. var state = await handler(input); AnnounceInput(input); if (!CanChangeState(state)) { - // Don't save registered callbacks from the state we couldn't change to. - ExitCallbacks.Clear(); - EnterCallbacks.Clear(); - Flip(); // Restore previous enter/exit callbacks. + // The only time we can't change states is if the new state is + // equivalent to the old state (determined by the default equality + // comparer) continue; } - // Restore previous enter/exit callbacks. - Flip(); var previous = Value; - // Call previously registered OnExit callbacks. - foreach (var onExit in ExitCallbacks) { - if (onExit.IsType(state)) { - // Not actually leaving this state type. - continue; + // Call OnExit callbacks for StateLogic states. + if (previous is StateLogic previousLogic) { + foreach (var onExit in previousLogic.InternalState.ExitCallbacks) { + if (onExit.IsType(state)) { + // Not actually leaving this state type. + continue; + } + await RunSafe(() => onExit.Callback(state)); } - await RunSafe(async () => await onExit.Callback(state)); } - // Now that exit callbacks have run, clear them. - ExitCallbacks.Clear(); - // The previous state already had its enter callbacks run. - EnterCallbacks.Clear(); - SetState(state); - // Use new state's enter callbacks right now. - Flip(); - - // Call newly registered OnEnter callbacks. - foreach (var onEnter in EnterCallbacks) { - if (onEnter.IsType(previous)) { - // Already entered this state type. - continue; + // Call OnEnter callbacks for StateLogic states. + if (state is StateLogic stateLogic) { + foreach (var onEnter in stateLogic.InternalState.EnterCallbacks) { + if (onEnter.IsType(previous)) { + // Already entered this state type. + continue; + } + await RunSafe(() => onEnter.Callback(previous)); } - await RunSafe(() => onEnter.Callback(previous)); } FinalizeStateChange(state); diff --git a/README.md b/README.md index 9cf6e17..7914303 100644 --- a/README.md +++ b/README.md @@ -250,11 +250,11 @@ In the case of `Off`, we only need to handle the `TurnOn` event. Input handlers ) { var tempSensor = context.Get(); - context.OnEnter( + OnEnter( (previous) => tempSensor.OnTemperatureChanged += OnTemperatureChanged ); - context.OnExit( + OnExit( (next) => tempSensor.OnTemperatureChanged -= OnTemperatureChanged ); }