From 2c738956d43c317fcc35b6aeeab324babe31d4d8 Mon Sep 17 00:00:00 2001 From: Joanna May Date: Sun, 27 Aug 2023 00:13:53 -0500 Subject: [PATCH] feat: allow logic blocks to be tested --- .../VendingMachine.cs | 14 +- .../test_cases/Heater.cs | 10 +- .../test_cases/LightSwitch.cs | 8 +- .../test_cases/Patterns.cs | 10 +- .../test_cases/SingleState.cs | 4 +- .../test_cases/ToasterOven.cs | 12 +- .../PartialLogic1.cs | 4 +- .../PartialLogic2.cs | 4 +- .../Chickensoft.LogicBlocks.Generator.csproj | 2 +- .../src/common/utils/Constants.cs | 2 +- .../Chickensoft.LogicBlocks.Tests.csproj | 1 + .../test/fixtures/FakeLogicBlock.cs | 24 +-- .../test/fixtures/FakeLogicBlockAsync.cs | 22 +-- .../test/fixtures/MyLogicBlock.cs | 38 ++++ .../test/fixtures/MyLogicBlock.g.puml | 11 ++ .../test/fixtures/MyObject.cs | 13 ++ .../test/fixtures/NonEquatable.cs | 10 +- .../test/fixtures/TestMachine.cs | 12 +- .../test/fixtures/TestMachineAsync.cs | 12 +- .../test/fixtures/TestMachineReusable.cs | 12 +- .../test/fixtures/TestMachineReusableAsync.cs | 12 +- .../test/src/ExampleTest.g.puml | 3 + .../test/src/LogicBlock.BindingTest.cs | 22 +++ .../examples/MyLogicBlock.SomeStateTest.cs | 28 +++ .../test/src/examples/MyObjectTest.cs | 37 ++++ Chickensoft.LogicBlocks/src/Logic.Binding.cs | 171 +++++++++++------- Chickensoft.LogicBlocks/src/Logic.Context.cs | 64 ++++--- .../src/Logic.StateLogic.cs | 6 +- Chickensoft.LogicBlocks/src/Logic.cs | 125 +++++++++---- Chickensoft.LogicBlocks/src/LogicBlock.cs | 54 ++++-- .../src/LogicBlockAsync.cs | 67 ++++++- README.md | 167 ++++++++++++++--- 32 files changed, 719 insertions(+), 262 deletions(-) create mode 100644 Chickensoft.LogicBlocks.Tests/test/fixtures/MyLogicBlock.cs create mode 100644 Chickensoft.LogicBlocks.Tests/test/fixtures/MyLogicBlock.g.puml create mode 100644 Chickensoft.LogicBlocks.Tests/test/fixtures/MyObject.cs create mode 100644 Chickensoft.LogicBlocks.Tests/test/src/ExampleTest.g.puml create mode 100644 Chickensoft.LogicBlocks.Tests/test/src/examples/MyLogicBlock.SomeStateTest.cs create mode 100644 Chickensoft.LogicBlocks.Tests/test/src/examples/MyObjectTest.cs diff --git a/Chickensoft.LogicBlocks.Example/VendingMachine.cs b/Chickensoft.LogicBlocks.Example/VendingMachine.cs index 190586e..d2ca2fb 100644 --- a/Chickensoft.LogicBlocks.Example/VendingMachine.cs +++ b/Chickensoft.LogicBlocks.Example/VendingMachine.cs @@ -13,10 +13,10 @@ public record TransactionTimedOut : Input; public record VendingCompleted : Input; } - public abstract record State(Context Context) : StateLogic(Context) { + public abstract record State(IContext Context) : StateLogic(Context) { public record Idle : State, IGet, IGet { - public Idle(Context context) : base(context) { + public Idle(IContext context) : base(context) { OnEnter((previous) => context.Output( new Output.ClearTransactionTimeOutTimer() )); @@ -49,7 +49,7 @@ public abstract record TransactionActive : State, public int AmountReceived { get; } public TransactionActive( - Context context, ItemType type, int price, int amountReceived + IContext context, ItemType type, int price, int amountReceived ) : base(context) { Type = type; Price = price; @@ -95,7 +95,7 @@ public State On(Input.TransactionTimedOut input) { public record Started : TransactionActive, IGet { public Started( - Context context, ItemType type, int price, int amountReceived + IContext context, ItemType type, int price, int amountReceived ) : base(context, type, price, amountReceived) { OnEnter( (previous) => context.Output(new Output.TransactionStarted()) @@ -115,7 +115,7 @@ public State On(Input.SelectionEntered input) { } public record PaymentPending( - Context Context, ItemType Type, int Price, int AmountReceived + IContext Context, ItemType Type, int Price, int AmountReceived ) : TransactionActive(Context, Type, Price, AmountReceived); } @@ -123,7 +123,7 @@ public record Vending : State, IGet { public ItemType Type { get; } public int Price { get; } - public Vending(Context context, ItemType type, int price) : + public Vending(IContext context, ItemType type, int price) : base(context) { Type = type; Price = price; @@ -171,7 +171,7 @@ public VendingMachine(VendingMachineStock stock) { Set(stock); } - public override State GetInitialState(Context context) + public override State GetInitialState(IContext context) => new State.Idle(context); } diff --git a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/Heater.cs b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/Heater.cs index c552f55..5dd17f9 100644 --- a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/Heater.cs +++ b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/Heater.cs @@ -21,7 +21,7 @@ public Heater(ITemperatureSensor tempSensor) { Set(tempSensor); } - public override State GetInitialState(Context context) => + public override State GetInitialState(IContext context) => new State.Off(context, 72.0); public abstract record Input { @@ -31,23 +31,23 @@ public record TargetTempChanged(double Temp) : Input; public record AirTempSensorChanged(double AirTemp) : Input; } - public abstract record State(Context Context, double TargetTemp) + public abstract record State(IContext Context, double TargetTemp) : StateLogic(Context) { public record Off( - Context Context, double TargetTemp + IContext Context, double TargetTemp ) : State(Context, TargetTemp), IGet { State IGet.On(Input.TurnOn input) => new Heating(Context, TargetTemp); } - public record Idle(Context Context, double TargetTemp) : + public record Idle(IContext Context, double TargetTemp) : State(Context, TargetTemp); public record Heating : State, IGet, IGet, IGet { - public Heating(Context context, double targetTemp) : base( + public Heating(IContext context, double targetTemp) : base( context, targetTemp ) { var tempSensor = context.Get(); diff --git a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/LightSwitch.cs b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/LightSwitch.cs index 0ac8892..7850c88 100644 --- a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/LightSwitch.cs +++ b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/LightSwitch.cs @@ -2,19 +2,19 @@ namespace Chickensoft.LogicBlocks.Generator.Tests; [StateMachine] public class LightSwitch : LogicBlock { - public override State GetInitialState(Context context) => + public override State GetInitialState(IContext context) => new State.Off(context); public abstract record Input { public record Toggle : Input; } - public abstract record State(Context Context) : StateLogic(Context) { - public record On(Context Context) : State(Context), IGet { + public abstract record State(IContext Context) : StateLogic(Context) { + public record On(IContext Context) : State(Context), IGet { State IGet.On(Input.Toggle input) => new Off(Context); } - public record Off(Context Context) : State(Context), IGet { + public record Off(IContext Context) : State(Context), IGet { State IGet.On(Input.Toggle input) => new On(Context); } } diff --git a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/Patterns.cs b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/Patterns.cs index 469a0f4..70f722c 100644 --- a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/Patterns.cs +++ b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/Patterns.cs @@ -4,14 +4,14 @@ namespace Chickensoft.LogicBlocks.Generator.Tests; public class Patterns : LogicBlock { public enum Mode { One, Two, Three } - public override State GetInitialState(Context context) => + public override State GetInitialState(IContext context) => new State.One(context); public abstract record Input { public record SetMode(Mode Mode) : Input; } - public abstract record State(Context Context) : StateLogic(Context), IGet { + public abstract record State(IContext Context) : StateLogic(Context), IGet { public State On(Input.SetMode input) => input.Mode switch { Mode.One => new One(Context), Mode.Two => new Two(Context), @@ -21,9 +21,9 @@ public abstract record State(Context Context) : StateLogic(Context), IGet throw new NotImplementedException() }; - public record One(Context Context) : State(Context); - public record Two(Context Context) : State(Context); - public record Three(Context Context) : State(Context); + public record One(IContext Context) : State(Context); + public record Two(IContext Context) : State(Context); + public record Three(IContext Context) : State(Context); } public abstract record Output { } diff --git a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/SingleState.cs b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/SingleState.cs index ca3f1b1..2ee096f 100644 --- a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/SingleState.cs +++ b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/SingleState.cs @@ -3,13 +3,13 @@ namespace Chickensoft.LogicBlocks.Generator.Tests; [StateMachine] public class SingleState : LogicBlock { - public override State GetInitialState(Context context) => new(Context); + public override State GetInitialState(IContext context) => new(Context); public abstract record Input { public record MyInput : Input { } } public record State : StateLogic, IGet { - public State(Context context) : base(context) { + public State(IContext context) : base(context) { OnEnter((previous) => Context.Output(new Output.MyOutput())); OnExit((next) => Context.Output(new Output.MyOutput())); } diff --git a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/ToasterOven.cs b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/ToasterOven.cs index 635fb45..d9145f7 100644 --- a/Chickensoft.LogicBlocks.Generator.Tests/test_cases/ToasterOven.cs +++ b/Chickensoft.LogicBlocks.Generator.Tests/test_cases/ToasterOven.cs @@ -2,7 +2,7 @@ namespace Chickensoft.LogicBlocks.Generator.Tests; [StateMachine] public class ToasterOven : LogicBlock { - public override State GetInitialState(Context context) => + public override State GetInitialState(IContext context) => new State.Toasting(context, 0); public record Input { @@ -12,9 +12,9 @@ public record StartBaking(int Temperature) : Input; public record StartToasting(int ToastColor) : Input; } - public abstract record State(Context Context) : StateLogic(Context) { + public abstract record State(IContext Context) : StateLogic(Context) { public record Heating : State, IGet { - public Heating(Context context) : base(context) { + public Heating(IContext context) : base(context) { OnEnter( (previous) => Context.Output(new Output.TurnHeaterOn()) ); @@ -29,7 +29,7 @@ public Heating(Context context) : base(context) { public record Toasting : Heating, IGet { public int ToastColor { get; } - public Toasting(Context context, int toastColor) : base(context) { + public Toasting(IContext context, int toastColor) : base(context) { ToastColor = toastColor; OnEnter( @@ -48,7 +48,7 @@ public Toasting(Context context, int toastColor) : base(context) { public record Baking : Heating, IGet { public int Temperature { get; } - public Baking(Context context, int temperature) : base(context) { + public Baking(IContext context, int temperature) : base(context) { Temperature = temperature; OnEnter( @@ -65,7 +65,7 @@ public Baking(Context context, int temperature) : base(context) { } public record DoorOpen : State, IGet { - public DoorOpen(Context context) : base(context) { + public DoorOpen(IContext context) : base(context) { OnEnter( (previous) => Context.Output(new Output.TurnLampOn()) ); 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 d2746de..8ce4c2f 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 @@ -5,13 +5,13 @@ namespace Chickensoft.LogicBlocks.Generator.Tests; [StateMachine] public partial class PartialLogic : LogicBlock { - public override State GetInitialState(Context context) => + public override State GetInitialState(IContext context) => throw new NotImplementedException(); public abstract record Input { public record One : Input; } - public abstract partial record State(Context Context) : StateLogic(Context); + public abstract partial record State(IContext Context) : StateLogic(Context); public abstract record Output { public record OutputA : Output; public record OutputEnterA : Output; 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 fdcfd45..f4a5d10 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 @@ -4,7 +4,7 @@ public partial class PartialLogic : LogicBlock { public abstract partial record State : StateLogic { public partial record A : State, IGet { - public A(Context context) : base(context) { + public A(IContext context) : base(context) { OnEnter( (previous) => Context.Output(new Output.OutputEnterA()) ); @@ -14,6 +14,6 @@ public A(Context context) : base(context) { } } - public record B(Context Context) : State(Context); + public record B(IContext Context) : State(Context); } } diff --git a/Chickensoft.LogicBlocks.Generator/Chickensoft.LogicBlocks.Generator.csproj b/Chickensoft.LogicBlocks.Generator/Chickensoft.LogicBlocks.Generator.csproj index 3890243..b71a428 100644 --- a/Chickensoft.LogicBlocks.Generator/Chickensoft.LogicBlocks.Generator.csproj +++ b/Chickensoft.LogicBlocks.Generator/Chickensoft.LogicBlocks.Generator.csproj @@ -11,7 +11,7 @@ NU5128 LogicBlocks Generator - 2.2.0 + 2.3.0 © 2023 Chickensoft Games Chickensoft diff --git a/Chickensoft.LogicBlocks.Generator/src/common/utils/Constants.cs b/Chickensoft.LogicBlocks.Generator/src/common/utils/Constants.cs index 5cde8d4..50e18fa 100644 --- a/Chickensoft.LogicBlocks.Generator/src/common/utils/Constants.cs +++ b/Chickensoft.LogicBlocks.Generator/src/common/utils/Constants.cs @@ -8,7 +8,7 @@ public class Constants { public static int SPACES_PER_INDENT = 2; 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_ID = "global::Chickensoft.LogicBlocks.Logic.IContext"; public const string LOGIC_BLOCK_CONTEXT_OUTPUT = "Output"; public const string LOGIC_BLOCK_STATE_LOGIC_ON_ENTER = "OnEnter"; public const string LOGIC_BLOCK_STATE_LOGIC_ON_EXIT = "OnExit"; diff --git a/Chickensoft.LogicBlocks.Tests/Chickensoft.LogicBlocks.Tests.csproj b/Chickensoft.LogicBlocks.Tests/Chickensoft.LogicBlocks.Tests.csproj index 49ffbe0..81fdf41 100644 --- a/Chickensoft.LogicBlocks.Tests/Chickensoft.LogicBlocks.Tests.csproj +++ b/Chickensoft.LogicBlocks.Tests/Chickensoft.LogicBlocks.Tests.csproj @@ -12,6 +12,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlock.cs b/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlock.cs index eb0184e..2da8af7 100644 --- a/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlock.cs +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlock.cs @@ -17,12 +17,12 @@ public record NoNewState() : Input; public record SelfInput(Input Input) : Input; public record InputCallback( Action Callback, - Func Next + Func Next ) : Input; - public record Custom(Func Next) : Input; + public record Custom(Func Next) : Input; } - public abstract record State(Context Context) : StateLogic(Context), + public abstract record State(IContext Context) : StateLogic(Context), IGet, IGet, IGet, @@ -67,26 +67,26 @@ public State On(Input.InputCallback input) { public State On(Input.SelfInput input) => Context.Input(input.Input); - public record StateA(Context Context, int Value1, int Value2) : + public record StateA(IContext Context, int Value1, int Value2) : State(Context); - public record StateB(Context Context, string Value1, string Value2) : + public record StateB(IContext Context, string Value1, string Value2) : State(Context); - public record StateC(Context Context, string Value) : + public record StateC(IContext Context, string Value) : State(Context); - public record StateD(Context Context, string Value1, string Value2) : + public record StateD(IContext Context, string Value1, string Value2) : State(Context); - public record NothingState(Context Context) : State(Context); + public record NothingState(IContext Context) : State(Context); public record Custom : State { - public Custom(Context context, Action setupCallback) : + public Custom(IContext context, Action setupCallback) : base(context) { setupCallback(context); } } public record OnEnterState : State { - public OnEnterState(Context context, Action onEnter) : + public OnEnterState(IContext context, Action onEnter) : base(context) { OnEnter(onEnter); } @@ -103,13 +103,13 @@ public partial class FakeLogicBlock : LogicBlock< FakeLogicBlock.Input, FakeLogicBlock.State, FakeLogicBlock.Output > { - public Func? InitialState { get; init; } + public Func? InitialState { get; init; } public List Exceptions { get; } = new(); public void PublicSet(T value) where T : notnull => Set(value); - public override State GetInitialState(Context context) => + public override State GetInitialState(IContext context) => InitialState?.Invoke(context) ?? new State.StateA(context, 1, 2); private readonly Action? _onError; diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlockAsync.cs b/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlockAsync.cs index dd15fc8..43dc1c1 100644 --- a/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlockAsync.cs +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/FakeLogicBlockAsync.cs @@ -15,12 +15,12 @@ public record NoNewState() : Input; public record SelfInput(Input Input) : Input; public record InputCallback( Action Callback, - Func Next + Func Next ) : Input; - public record Custom(Func Next) : Input; + public record Custom(Func Next) : Input; } - public abstract record State(Context Context) : StateLogic(Context), + public abstract record State(IContext Context) : StateLogic(Context), IGet, IGet, IGet, @@ -70,23 +70,23 @@ public async Task On(Input.SelfInput input) { return this; } - public record StateA(Context Context, int Value1, int Value2) : + public record StateA(IContext Context, int Value1, int Value2) : State(Context); - public record StateB(Context Context, string Value1, string Value2) : + public record StateB(IContext Context, string Value1, string Value2) : State(Context); - public record StateC(Context Context, string Value) : + public record StateC(IContext Context, string Value) : State(Context); - public record StateD(Context Context, string Value1, string Value2) : + public record StateD(IContext Context, string Value1, string Value2) : State(Context); public record Custom : State { - public Custom(Context context, Action setupCallback) : + public Custom(IContext context, Action setupCallback) : base(context) { setupCallback(context); } } public record OnEnterState : State { - public OnEnterState(Context context, Func onEnter) : + public OnEnterState(IContext context, Func onEnter) : base(context) { OnEnter(onEnter); } @@ -103,11 +103,11 @@ public partial class FakeLogicBlockAsync : LogicBlockAsync< FakeLogicBlockAsync.Input, FakeLogicBlockAsync.State, FakeLogicBlockAsync.Output > { - public Func? InitialState { get; init; } + public Func? InitialState { get; init; } public List Exceptions { get; } = new(); - public override State GetInitialState(Context context) => + public override State GetInitialState(IContext context) => InitialState?.Invoke(context) ?? new State.StateA(context, 1, 2); private readonly Action? _onError; diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/MyLogicBlock.cs b/Chickensoft.LogicBlocks.Tests/test/fixtures/MyLogicBlock.cs new file mode 100644 index 0000000..acc44ef --- /dev/null +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/MyLogicBlock.cs @@ -0,0 +1,38 @@ +namespace Chickensoft.LogicBlocks.Tests.Fixtures; + +using Chickensoft.LogicBlocks.Generator; + +[StateMachine] +public partial class MyLogicBlock : + LogicBlock { + public override State GetInitialState(IContext context) => + new State.SomeState(context); + + public abstract record Input { + public record SomeInput : Input; + public record SomeOtherInput : Input; + } + + public abstract record State(IContext Context) : StateLogic(Context) { + public record SomeState(IContext Context) : State(Context), + IGet { + public State On(Input.SomeInput input) { + Context.Output(new Output.SomeOutput()); + return new SomeOtherState(Context); + } + } + + public record SomeOtherState(IContext Context) : State(Context), + IGet { + public State On(Input.SomeOtherInput input) { + Context.Output(new Output.SomeOtherOutput()); + return new SomeState(Context); + } + } + } + + public abstract record Output { + public record SomeOutput : Output; + public record SomeOtherOutput : Output; + } +} diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/MyLogicBlock.g.puml b/Chickensoft.LogicBlocks.Tests/test/fixtures/MyLogicBlock.g.puml new file mode 100644 index 0000000..da5e205 --- /dev/null +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/MyLogicBlock.g.puml @@ -0,0 +1,11 @@ +@startuml MyLogicBlock +state "MyLogicBlock State" as Chickensoft_LogicBlocks_Tests_Fixtures_MyLogicBlock_State { + state "SomeState" as Chickensoft_LogicBlocks_Tests_Fixtures_MyLogicBlock_State_SomeState + state "SomeOtherState" as Chickensoft_LogicBlocks_Tests_Fixtures_MyLogicBlock_State_SomeOtherState +} + +Chickensoft_LogicBlocks_Tests_Fixtures_MyLogicBlock_State_SomeOtherState --> Chickensoft_LogicBlocks_Tests_Fixtures_MyLogicBlock_State_SomeState : SomeOtherInput +Chickensoft_LogicBlocks_Tests_Fixtures_MyLogicBlock_State_SomeState --> Chickensoft_LogicBlocks_Tests_Fixtures_MyLogicBlock_State_SomeOtherState : SomeInput + +[*] --> Chickensoft_LogicBlocks_Tests_Fixtures_MyLogicBlock_State_SomeState +@enduml \ No newline at end of file diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/MyObject.cs b/Chickensoft.LogicBlocks.Tests/test/fixtures/MyObject.cs new file mode 100644 index 0000000..9db81ef --- /dev/null +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/MyObject.cs @@ -0,0 +1,13 @@ +namespace Chickensoft.LogicBlocks.Tests.Fixtures; + +public class MyObject { + public MyLogicBlock Logic { get; } + + public MyObject(MyLogicBlock logic) { + Logic = logic; + } + + // Method we want to test + public MyLogicBlock.State DoSomething() => + Logic.Input(new MyLogicBlock.Input.SomeInput()); +} diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/NonEquatable.cs b/Chickensoft.LogicBlocks.Tests/test/fixtures/NonEquatable.cs index a120ba2..c39f1af 100644 --- a/Chickensoft.LogicBlocks.Tests/test/fixtures/NonEquatable.cs +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/NonEquatable.cs @@ -8,17 +8,17 @@ public record GoToB : Input; public abstract class State : IStateLogic, IGet, IGet { - public Context Context { get; } - public State(Context context) { + public IContext Context { get; } + public State(IContext context) { Context = context; } public class A : State { - public A(Context context) : base(context) { } + public A(IContext context) : base(context) { } } public class B : State { - public B(Context context) : base(context) { } + public B(IContext context) : base(context) { } } public State On(Input.GoToA input) => new A(Context); @@ -27,6 +27,6 @@ public B(Context context) : base(context) { } public class Output { } - public override State GetInitialState(Context context) => + public override State GetInitialState(IContext context) => new State.A(context); } diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachine.cs b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachine.cs index 6499f1d..ecd1771 100644 --- a/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachine.cs +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachine.cs @@ -15,7 +15,7 @@ public record Activate(SecondaryState Secondary) : Input; public record Deactivate() : Input; } - public abstract record State(Context Context) : StateLogic(Context), + public abstract record State(IContext Context) : StateLogic(Context), IGet { public State On(Input.Activate input) => input.Secondary switch { @@ -25,7 +25,7 @@ public State On(Input.Activate input) => }; public abstract record Activated : State, IGet { - public Activated(Context context) : base(context) { + public Activated(IContext context) : base(context) { OnEnter( (previous) => context.Output(new Output.Activated()) ); @@ -37,7 +37,7 @@ public Activated(Context context) : base(context) { public State On(Input.Deactivate input) => new Deactivated(Context); public record Blooped : Activated { - public Blooped(Context context) : base(context) { + public Blooped(IContext context) : base(context) { OnEnter( (previous) => context.Output(new Output.Blooped()) ); @@ -48,7 +48,7 @@ public Blooped(Context context) : base(context) { } public record Bopped : Activated { - public Bopped(Context context) : base(context) { + public Bopped(IContext context) : base(context) { OnEnter( (previous) => context.Output(new Output.Bopped()) ); @@ -60,7 +60,7 @@ public Bopped(Context context) : base(context) { } public record Deactivated : State { - public Deactivated(Context context) : base(context) { + public Deactivated(IContext context) : base(context) { OnEnter( (previous) => context.Output(new Output.Deactivated()) ); @@ -82,6 +82,6 @@ public record Bopped() : Output; public record BoppedCleanUp() : Output; } - public override State GetInitialState(Context context) => + public override State GetInitialState(IContext context) => new State.Deactivated(context); } diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineAsync.cs b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineAsync.cs index 85ff828..f5d72b8 100644 --- a/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineAsync.cs +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineAsync.cs @@ -9,7 +9,7 @@ public record Activate(SecondaryState Secondary) : Input; public record Deactivate() : Input; } - public abstract record State(Context Context) : StateLogic(Context), + public abstract record State(IContext Context) : StateLogic(Context), IGet { public async Task On(Input.Activate input) { await Task.Delay(5); @@ -22,7 +22,7 @@ public async Task On(Input.Activate input) { } public abstract record Activated : State, IGet { - public Activated(Context context) : base(context) { + public Activated(IContext context) : base(context) { OnEnter( async (previous) => { await Task.Delay(10); @@ -41,7 +41,7 @@ public async Task On(Input.Deactivate input) => new Deactivated(Context); public record Blooped : Activated { - public Blooped(Context context) : base(context) { + public Blooped(IContext context) : base(context) { OnEnter( async (previous) => { await Task.Delay(10); @@ -58,7 +58,7 @@ public Blooped(Context context) : base(context) { } public record Bopped : Activated { - public Bopped(Context context) : base(context) { + public Bopped(IContext context) : base(context) { OnEnter( async (previous) => { await Task.Delay(10); @@ -76,7 +76,7 @@ public Bopped(Context context) : base(context) { } public record Deactivated : State { - public Deactivated(Context context) : base(context) { + public Deactivated(IContext context) : base(context) { OnEnter( async (previous) => { await Task.Delay(20); @@ -104,7 +104,7 @@ public record Bopped() : Output; public record BoppedCleanUp() : Output; } - public override State GetInitialState(Context context) => + public override State GetInitialState(IContext context) => new State.Deactivated(context); } diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusable.cs b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusable.cs index 97fef9d..7ecaca3 100644 --- a/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusable.cs +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusable.cs @@ -14,7 +14,7 @@ public record Activate(SecondaryState Secondary) : Input; public record Deactivate() : Input; } - public abstract record State(Context Context) : StateLogic(Context), + public abstract record State(IContext Context) : StateLogic(Context), IGet { public State On(Input.Activate input) => input.Secondary switch { @@ -24,7 +24,7 @@ public State On(Input.Activate input) => }; public abstract record Activated : State, IGet { - public Activated(Context context) : base(context) { + public Activated(IContext context) : base(context) { OnEnter( (previous) => context.Output(new Output.Activated()) ); @@ -36,7 +36,7 @@ public Activated(Context context) : base(context) { public State On(Input.Deactivate input) => Context.Get(); public record Blooped : Activated { - public Blooped(Context context) : base(context) { + public Blooped(IContext context) : base(context) { OnEnter( (previous) => context.Output(new Output.Blooped()) ); @@ -47,7 +47,7 @@ public Blooped(Context context) : base(context) { } public record Bopped : Activated { - public Bopped(Context context) : base(context) { + public Bopped(IContext context) : base(context) { OnEnter( (previous) => context.Output(new Output.Bopped()) ); @@ -59,7 +59,7 @@ public Bopped(Context context) : base(context) { } public record Deactivated : State { - public Deactivated(Context context) : base(context) { + public Deactivated(IContext context) : base(context) { OnEnter( (previous) => context.Output(new Output.Deactivated()) ); @@ -87,6 +87,6 @@ public TestMachineReusable() { Set(new State.Deactivated(Context)); } - public override State GetInitialState(Context context) => + public override State GetInitialState(IContext context) => context.Get(); } diff --git a/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusableAsync.cs b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusableAsync.cs index 75e220c..1d16534 100644 --- a/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusableAsync.cs +++ b/Chickensoft.LogicBlocks.Tests/test/fixtures/TestMachineReusableAsync.cs @@ -15,7 +15,7 @@ public record Activate(SecondaryState Secondary) : Input; public record Deactivate() : Input; } - public abstract record State(Context Context) : StateLogic(Context), + public abstract record State(IContext Context) : StateLogic(Context), IGet { public async Task On(Input.Activate input) { await Task.Delay(5); @@ -28,7 +28,7 @@ public async Task On(Input.Activate input) { } public abstract record Activated : State, IGet { - public Activated(Context context) : base(context) { + public Activated(IContext context) : base(context) { OnEnter( async (previous) => { await Task.Delay(10); @@ -47,7 +47,7 @@ public async Task On(Input.Deactivate input) => Context.Get(); public record Blooped : Activated { - public Blooped(Context context) : base(context) { + public Blooped(IContext context) : base(context) { OnEnter( async (previous) => { await Task.Delay(10); @@ -64,7 +64,7 @@ public Blooped(Context context) : base(context) { } public record Bopped : Activated { - public Bopped(Context context) : base(context) { + public Bopped(IContext context) : base(context) { OnEnter( async (previous) => { await Task.Delay(10); @@ -82,7 +82,7 @@ public Bopped(Context context) : base(context) { } public record Deactivated : State { - public Deactivated(Context context) : base(context) { + public Deactivated(IContext context) : base(context) { OnEnter( async (previous) => { await Task.Delay(20); @@ -116,7 +116,7 @@ public TestMachineReusableAsync() { Set(new State.Deactivated(Context)); } - public override State GetInitialState(Context context) => + public override State GetInitialState(IContext context) => context.Get(); } diff --git a/Chickensoft.LogicBlocks.Tests/test/src/ExampleTest.g.puml b/Chickensoft.LogicBlocks.Tests/test/src/ExampleTest.g.puml new file mode 100644 index 0000000..2ad50a9 --- /dev/null +++ b/Chickensoft.LogicBlocks.Tests/test/src/ExampleTest.g.puml @@ -0,0 +1,3 @@ +@startuml MyLogicBlock +state "MyLogicBlock State" as Chickensoft_LogicBlocks_Tests_Fixtures_MyLogicBlock_State +@enduml \ No newline at end of file diff --git a/Chickensoft.LogicBlocks.Tests/test/src/LogicBlock.BindingTest.cs b/Chickensoft.LogicBlocks.Tests/test/src/LogicBlock.BindingTest.cs index 48cda0d..122f49a 100644 --- a/Chickensoft.LogicBlocks.Tests/test/src/LogicBlock.BindingTest.cs +++ b/Chickensoft.LogicBlocks.Tests/test/src/LogicBlock.BindingTest.cs @@ -2,6 +2,7 @@ namespace Chickensoft.LogicBlocks.Tests; using Chickensoft.LogicBlocks.Tests.Fixtures; using Chickensoft.LogicBlocks.Tests.TestUtils; +using Moq; using Shouldly; using Xunit; @@ -242,6 +243,27 @@ public void CatchesExceptions() { called.ShouldBeTrue(); } + [Fact] + public void CanBeMocked() { + var logic = new Mock< + ILogicBlock< + FakeLogicBlock.Input, FakeLogicBlock.State, FakeLogicBlock.Output + > + >(); + + var binding = new Mock(); + var context = new Mock(); + + var input = new FakeLogicBlock.Input.InputOne(1, 2); + var state = new FakeLogicBlock.State.StateA(context.Object, 1, 2); + + logic.Setup(logic => logic.Bind()).Returns(binding.Object); + logic.Setup(logic => logic.Input(input)).Returns(state); + + logic.Object.Bind().ShouldBe(binding.Object); + logic.Object.Input(input).ShouldBe(state); + } + [Fact] public void Finalizes() { // Weak reference has to be created and cleared from a static function diff --git a/Chickensoft.LogicBlocks.Tests/test/src/examples/MyLogicBlock.SomeStateTest.cs b/Chickensoft.LogicBlocks.Tests/test/src/examples/MyLogicBlock.SomeStateTest.cs new file mode 100644 index 0000000..a7ce9ca --- /dev/null +++ b/Chickensoft.LogicBlocks.Tests/test/src/examples/MyLogicBlock.SomeStateTest.cs @@ -0,0 +1,28 @@ +namespace Chickensoft.LogicBlocks.Tests.Examples; + +using Chickensoft.LogicBlocks.Tests.Fixtures; +using Moq; +using Shouldly; +using Xunit; + +public class SomeStateTest { + [Fact] + public void HandlesSomeInput() { + var context = new Mock(); + var state = new MyLogicBlock.State.SomeState(context.Object); + + // Expect our state to output SomeOutput when SomeInput is received. + context + .Setup(context => context.Output(new MyLogicBlock.Output.SomeOutput())); + + // Perform the action we are testing on our state. + var result = state.On(new MyLogicBlock.Input.SomeInput()); + + // Make sure the output we expected was produced by ensuring our mock + // context was called the same way we set it up. + context.VerifyAll(); + + // Make sure we got the next state. + result.ShouldBeOfType(); + } +} diff --git a/Chickensoft.LogicBlocks.Tests/test/src/examples/MyObjectTest.cs b/Chickensoft.LogicBlocks.Tests/test/src/examples/MyObjectTest.cs new file mode 100644 index 0000000..9782030 --- /dev/null +++ b/Chickensoft.LogicBlocks.Tests/test/src/examples/MyObjectTest.cs @@ -0,0 +1,37 @@ +namespace Chickensoft.LogicBlocks.Tests.Examples; + +using Chickensoft.LogicBlocks.Tests.Fixtures; +using Moq; +using Shouldly; +using Xunit; + +public class MyObjectTest { + [Fact] + public void DoSomethingDoesSomething() { + // Our unit test follows the AAA pattern: Arrange, Act, Assert. + // Or Setup, Execute, and Verify, if you prefer. Etc. + + // Setup + var logic = new Mock(); + var context = new Mock(); + + var myObject = new MyObject(logic.Object); + + // Create a state that we expect to be returned. + var expectedState = new MyLogicBlock.State.SomeState(context.Object); + + // Setup the mock of the logic block to return our expected state whenever + // it receives the input SomeInput. + logic.Setup(logic => logic.Input(It.IsAny())) + .Returns(expectedState); + + // Execute the method we want to test. + var result = myObject.DoSomething(); + + // Verify that method returned the correct value. + result.ShouldBe(expectedState); + + // Verify that the method invoked our logic block as expected. + logic.VerifyAll(); + } +} diff --git a/Chickensoft.LogicBlocks/src/Logic.Binding.cs b/Chickensoft.LogicBlocks/src/Logic.Binding.cs index 404f3f4..30c1c3f 100644 --- a/Chickensoft.LogicBlocks/src/Logic.Binding.cs +++ b/Chickensoft.LogicBlocks/src/Logic.Binding.cs @@ -7,10 +7,64 @@ public abstract partial class Logic< TInput, TState, TOutput, THandler, TInputReturn, TUpdate > { /// - /// Creates a binding to a logic block. + /// State bindings for a logic block. + /// + /// A binding allows you to select data from a logic block's state, invoke + /// methods when certain states occur, and handle outputs. Using bindings + /// enable you to write more declarative code and prevent unnecessary + /// updates when a state has changed but the relevant data within it has not. + /// /// - /// Logic block binding. - public Binding Bind() => new(this); + public interface IBinding : IDisposable { + /// Logic block that is being bound to. + Logic + LogicBlock { get; } + + /// + /// Register a callback to be invoked whenever an input type of + /// is encountered. + /// + /// Input callback handler. + /// Type of input to register a handler + /// for. + /// The current binding. + Binding Watch( + Action handler + ) where TInputType : TInput; + + /// Registers a binding for a specific type of state. + /// + /// Create a bindings group that allows you to register bindings for a + /// specific type of state. Bindings are callbacks that only run when the + /// specific type of state you specify with + /// is encountered. + /// + /// Type of state to bind to. + /// The new binding group. + IWhenBinding + When() where TStateType : TState; + + /// + /// Register a callback to be invoked whenever an output type of + /// is encountered. + /// + /// Output callback handler. + /// Type of output to register a handler + /// for. + /// The current binding. + Binding Handle(Action handler) + where TOutputType : TOutput; + + /// + /// Register a callback to be invoked whenever an error type of + /// is encountered. + /// + /// Error callback handler. + /// Type of exception to handle. + /// The current binding. + Binding Catch(Action handler) + where TException : Exception; + } /// /// State bindings for a logic block. @@ -21,8 +75,8 @@ public abstract partial class Logic< /// updates when a state has changed but the relevant data within it has not. /// /// - public sealed class Binding : IDisposable { - /// Logic block that is being bound to. + public sealed class Binding : IBinding { + /// public Logic< TInput, TState, TOutput, THandler, TInputReturn, TUpdate > LogicBlock { get; } @@ -58,7 +112,8 @@ public Logic< private readonly List> _errorRunners; internal Binding( - Logic logicBlock + Logic + logicBlock ) { LogicBlock = logicBlock; _previousState = logicBlock.Value; @@ -75,14 +130,7 @@ Logic logicBlock LogicBlock.OnError += OnError; } - /// - /// Register a callback to be invoked whenever an input type of - /// is encountered. - /// - /// Input callback handler. - /// Type of input to register a handler - /// for. - /// The current binding. + /// public Binding Watch( Action handler ) where TInputType : TInput { @@ -92,16 +140,8 @@ Action handler return this; } - // Registers a binding for a specific type of state. - /// - /// Create a bindings group that allows you to register bindings for a - /// specific type of state. Bindings are callbacks that only run when the - /// specific type of state you specify with - /// is encountered. - /// - /// Type of state to bind to. - /// The new binding group. - public WhenBinding When() + /// + public IWhenBinding When() where TStateType : TState { var whenBinding = new WhenBinding(); // Add a closure to the list of when binding runners that invokes @@ -115,14 +155,7 @@ public WhenBinding When() return whenBinding; } - /// - /// Register a callback to be invoked whenever an output type of - /// is encountered. - /// - /// Output callback handler. - /// Type of output to register a handler - /// for. - /// The current binding. + /// public Binding Handle( Action handler ) where TOutputType : TOutput { @@ -132,13 +165,7 @@ Action handler return this; } - /// - /// Register a callback to be invoked whenever an error type of - /// is encountered. - /// - /// Error callback handler. - /// Type of exception to handle. - /// The current binding. + /// public Binding Catch( Action handler ) where TException : Exception { @@ -234,7 +261,43 @@ private void Cleanup() { /// state you specify with is encountered. /// /// Type of state to bind to. - public sealed class WhenBinding { + public interface IWhenBinding { + /// + /// Use data from the state to invoke a method that receives the data. This + /// allows you to "select" data from a given type of state and invoke a + /// callback only when the selected data actually changes (as determined by + /// reference equality and the default equality comparer). + /// + /// Data to select from the state type + /// . + /// Callback that receives the selected data and runs + /// only when the selected data has changed after a state update. + /// Type of data to select from the state. + /// + /// The current when-binding clause so that you can continue to use + /// data from the state or register callbacks. + IWhenBinding Use( + Func data, Action to + ) where TSelectedData : notnull; + + /// + /// Register a callback to be invoked whenever the state changes to the + /// state type . + /// + /// Callback invoked whenever the state changes to + /// the state type . + /// The current when-binding clause so that you can continue to use + /// data from the state or register callbacks. + IWhenBinding Call(Action callback); + } + + /// + /// A bindings group that allows you to register bindings for a specific type + /// of state. Bindings are callbacks that only run when the specific type of + /// state you specify with is encountered. + /// + /// Type of state to bind to. + internal sealed class WhenBinding : IWhenBinding { // Selected data bindings checkers registered with .Use() // These callbacks receive the current state, the previous state, the // selected data from the current state and the @@ -292,21 +355,8 @@ internal void Run(TState state, TState previous) { } } - /// - /// Use data from the state to invoke a method that receives the data. This - /// allows you to "select" data from a given type of state and invoke a - /// callback only when the selected data actually changes (as determined by - /// reference equality and the default equality comparer). - /// - /// Data to select from the state type - /// . - /// Callback that receives the selected data and runs - /// only when the selected data has changed after a state update. - /// Type of data to select from the state. - /// - /// The current when-binding clause so that you can continue to use - /// data from the state or register callbacks. - public WhenBinding Use( + /// + public IWhenBinding Use( Func data, Action to ) where TSelectedData : notnull { var checker = ( @@ -345,15 +395,8 @@ dynamic previousData return this; } - /// - /// Register a callback to be invoked whenever the state changes to the - /// state type . - /// - /// Callback invoked whenever the state changes to - /// the state type . - /// The current when-binding clause so that you can continue to use - /// data from the state or register callbacks. - public WhenBinding Call(Action callback) { + /// + public IWhenBinding Call(Action callback) { var handler = (dynamic state, TState previous) => callback((TStateType)state); diff --git a/Chickensoft.LogicBlocks/src/Logic.Context.cs b/Chickensoft.LogicBlocks/src/Logic.Context.cs index 7385856..8f2abb5 100644 --- a/Chickensoft.LogicBlocks/src/Logic.Context.cs +++ b/Chickensoft.LogicBlocks/src/Logic.Context.cs @@ -6,21 +6,7 @@ public abstract partial class Logic< TInput, TState, TOutput, THandler, TInputReturn, TUpdate > { /// Logic block context provided to each logic block state. - public readonly record struct Context { - private Logic< - TInput, TState, TOutput, THandler, TInputReturn, TUpdate - > Logic { get; } - - /// - /// Creates a new logic block context for the given logic block. - /// - /// Logic block. - public Context(Logic< - TInput, TState, TOutput, THandler, TInputReturn, TUpdate - > logic) { - Logic = logic; - } - + public interface IContext { /// /// Adds an input value to the logic block's internal input queue and /// returns the current state. @@ -35,31 +21,57 @@ public Context(Logic< /// Input to process. /// Type of the input. /// Logic block input return value. - public TState Input(TInputType input) - where TInputType : TInput { - Logic.Input(input); - return Logic.Value; - } - + TState Input(TInputType input) where TInputType : TInput; /// /// Produces a logic block output value. /// /// Output value. - public void Output(TOutput output) => Logic.OutputValue(output); - + void Output(TOutput output); /// /// Gets a value from the logic block's blackboard. /// /// Type of value to retrieve. /// The requested value. - public TDataType Get() where TDataType : notnull => - Logic.Get(); - + TDataType Get() where TDataType : notnull; /// /// Adds an error to a logic block. Errors are immediately processed by the /// logic block's callback. /// /// Exception to add. + void AddError(Exception e); + } + + /// Logic block context provided to each logic block state. + internal readonly record struct Context : IContext { + private Logic< + TInput, TState, TOutput, THandler, TInputReturn, TUpdate + > Logic { get; } + + /// + /// Creates a new logic block context for the given logic block. + /// + /// Logic block. + public Context(Logic< + TInput, TState, TOutput, THandler, TInputReturn, TUpdate + > logic) { + Logic = logic; + } + + /// + public TState Input(TInputType input) + where TInputType : TInput { + Logic.Input(input); + return Logic.Value; + } + + /// + public void Output(TOutput output) => Logic.OutputValue(output); + + /// + public TDataType Get() where TDataType : notnull => + Logic.Get(); + + /// public void AddError(Exception e) => Logic.AddError(e); } } diff --git a/Chickensoft.LogicBlocks/src/Logic.StateLogic.cs b/Chickensoft.LogicBlocks/src/Logic.StateLogic.cs index 6359cc9..6cb6f62 100644 --- a/Chickensoft.LogicBlocks/src/Logic.StateLogic.cs +++ b/Chickensoft.LogicBlocks/src/Logic.StateLogic.cs @@ -12,7 +12,7 @@ public abstract partial class Logic< /// public interface IStateLogic { /// Logic block context. - Context Context { get; } + IContext Context { get; } } internal class StateLogicState { @@ -46,7 +46,7 @@ public override int GetHashCode() => HashCode.Combine( /// public abstract record StateLogic : IStateLogic { /// Logic block context. - public Context Context { get; } + public IContext Context { get; } internal StateLogicState InternalState { get; } @@ -54,7 +54,7 @@ public abstract record StateLogic : IStateLogic { /// Creates a new instance of the logic block base state record. /// /// Logic block context. - public StateLogic(Context context) { + public StateLogic(IContext context) { Context = context; InternalState = new(); } diff --git a/Chickensoft.LogicBlocks/src/Logic.cs b/Chickensoft.LogicBlocks/src/Logic.cs index 5a809f7..08e8ebc 100644 --- a/Chickensoft.LogicBlocks/src/Logic.cs +++ b/Chickensoft.LogicBlocks/src/Logic.cs @@ -16,7 +16,7 @@ namespace Chickensoft.LogicBlocks; /// Input handler type. /// Input method return type. /// Update callback type. -public abstract partial class Logic< +public partial interface ILogic< TInput, TState, TOutput, THandler, TInputReturn, TUpdate > where TInput : notnull @@ -24,6 +24,76 @@ public abstract partial class Logic< TInput, TState, TOutput, THandler, TInputReturn, TUpdate >.IStateLogic where TOutput : notnull { + /// Current state of the logic block. + TState Value { get; } + /// + /// Whether or not the logic block is currently processing inputs. + /// + bool IsProcessing { get; } + /// Event invoked whenever an input is processed. + event EventHandler OnInput; + /// Event invoked whenever the state is updated. + event EventHandler OnState; + /// + /// Event invoked whenever an output is produced by an input handler. + /// + event EventHandler OnOutput; + /// + /// Event invoked whenever an error occurs in a state's input handler. + /// + event EventHandler OnError; + /// + /// Gets data from the blackboard. + /// + /// The type of data to retrieve. + /// + TData Get() where TData : notnull; + /// + /// Returns the initial state of the logic block. Implementations must + /// override this to provide a valid initial state. + /// + /// Initial logic block state. + TState GetInitialState(); + + /// + /// Adds an input value to the logic block's internal input queue. + /// + /// Input to process. + /// Type of the input. + /// Logic block input return value. + TInputReturn Input(TInputType input) where TInputType : TInput; + + /// + /// Creates a binding to a logic block. + /// + /// Logic block binding. + Logic< + TInput, TState, TOutput, THandler, TInputReturn, TUpdate + >.IBinding Bind(); +} + +/// +/// A logic block. Logic blocks are machines that receive input, maintain a +/// single state, and produce outputs. They can be used as simple +/// input-to-state reducers, or built upon to create hierarchical state +/// machines. +/// +/// Base input type. +/// Base state type. +/// Base output type. +/// Input handler type. +/// Input method return type. +/// Update callback type. +public abstract partial class Logic< + TInput, TState, TOutput, THandler, TInputReturn, TUpdate +> : + ILogic< + TInput, TState, TOutput, THandler, TInputReturn, TUpdate + > where TInput : notnull + where TState : Logic< + TInput, TState, TOutput, THandler, TInputReturn, TUpdate + >.IStateLogic + where TOutput : notnull { internal readonly struct PendingInput { public TInput Input { get; } /// @@ -61,44 +131,37 @@ public delegate void Transition( TStateTypeA stateA, TStateTypeB stateB ) where TStateTypeA : TState where TStateTypeB : TState; - /// Event invoked whenever an input is processed. + /// public event EventHandler OnInput { add => _inputEventSource.Subscribe(value); remove => _inputEventSource.Unsubscribe(value); } - /// Event invoked whenever the state is updated. + /// public event EventHandler OnState { add => _stateEventSource.Subscribe(value); remove => _stateEventSource.Unsubscribe(value); } + /// + public event EventHandler OnOutput { + add => _outputEventSource.Subscribe(value); + remove => _outputEventSource.Unsubscribe(value); + } - /// - /// Event invoked whenever an error occurs in an input handler. - /// + /// public event EventHandler OnError { add => _errorEventSource.Subscribe(value); remove => _errorEventSource.Unsubscribe(value); } - /// - /// Event invoked whenever an output is produced by an input handler. - /// - public event EventHandler OnOutput { - add => _outputEventSource.Subscribe(value); - remove => _outputEventSource.Unsubscribe(value); - } - - /// Current state of the logic block. + /// public TState Value => _value ??= GetInitialState(); - private TState? _value; - - /// - /// Whether or not the logic block is currently processing inputs. - /// + /// public abstract bool IsProcessing { get; } + private TState? _value; + private readonly Queue _inputs = new(); private readonly Dictionary _blackboard = new(); @@ -118,19 +181,13 @@ public event EventHandler OnOutput { /// internal Logic() { } - /// - /// Returns the initial state of the logic block. Implementations must - /// override this to provide a valid initial state. - /// - /// Initial logic block state. + /// + public IBinding Bind() => new Binding(this); + + /// public abstract TState GetInitialState(); - /// - /// Adds an input value to the logic block's internal input queue. - /// - /// Input to process. - /// Type of the input. - /// Logic block input return value. + /// public virtual TInputReturn Input(TInputType input) where TInputType : TInput { _inputs.Enqueue( @@ -245,11 +302,7 @@ internal void FinalizeStateChange(TState state) => internal void AnnounceInput(TInput input) => _inputEventSource.Raise(this, input); - /// - /// Gets data from the blackboard. - /// - /// The type of data to retrieve. - /// + /// public TData Get() where TData : notnull { var type = typeof(TData); return !_blackboard.TryGetValue(type, out var data) diff --git a/Chickensoft.LogicBlocks/src/LogicBlock.cs b/Chickensoft.LogicBlocks/src/LogicBlock.cs index 9a0426d..54d3287 100644 --- a/Chickensoft.LogicBlocks/src/LogicBlock.cs +++ b/Chickensoft.LogicBlocks/src/LogicBlock.cs @@ -17,9 +17,46 @@ namespace Chickensoft.LogicBlocks; /// Input type. /// State type. /// Output type. -public abstract partial class LogicBlock : - Logic, TState, Action> +public interface ILogicBlock + : ILogic< + TInput, TState, TOutput, Func, TState, Action + > where TInput : notnull + where TState : Logic< + TInput, TState, TOutput, Func, TState, Action + >.IStateLogic + where TOutput : notnull { + /// + /// Returns the initial state of the logic block. Implementations must + /// override this method to provide a valid initial state. + /// + /// Logic block context. + /// Initial state of the logic block. + TState GetInitialState(Logic< + TInput, TState, TOutput, Func, TState, Action + >.IContext context); +} + +/// +/// +/// A synchronous logic block. Logic blocks are machines that process inputs +/// one-at-a-time, maintain a current state graph, and produce outputs. +/// +/// +/// Logic blocks are essentially statecharts that are created using the state +/// pattern. Each state is a self-contained class, record, or struct that +/// implements . +/// +/// +/// Input type. +/// State type. +/// Output type. +public abstract partial class LogicBlock : + Logic< + TInput, TState, TOutput, Func, TState, Action + >, + ILogicBlock where TInput : notnull where TState : Logic< TInput, TState, @@ -32,7 +69,7 @@ public abstract partial class LogicBlock : /// /// The context provided to the states of the logic block. /// - public new Context Context { get; } + public new IContext Context { get; } /// /// Whether or not the logic block is processing inputs. @@ -43,19 +80,14 @@ public abstract partial class LogicBlock : /// Creates a new logic block. protected LogicBlock() { - Context = new(this); + Context = new Context(this); } /// public sealed override TState GetInitialState() => GetInitialState(Context); - /// - /// Returns the initial state of the logic block. Implementations must - /// override this method to provide a valid initial state. - /// - /// Logic block context. - /// Initial state of the logic block. - public abstract TState GetInitialState(Context context); + /// + public abstract TState GetInitialState(IContext context); internal override TState Process() { if (IsProcessing) { diff --git a/Chickensoft.LogicBlocks/src/LogicBlockAsync.cs b/Chickensoft.LogicBlocks/src/LogicBlockAsync.cs index 0c00e7d..a69fbb2 100644 --- a/Chickensoft.LogicBlocks/src/LogicBlockAsync.cs +++ b/Chickensoft.LogicBlocks/src/LogicBlockAsync.cs @@ -3,6 +3,57 @@ namespace Chickensoft.LogicBlocks; using System; using System.Threading.Tasks; +/// +/// +/// A synchronous logic block. Logic blocks are machines that process inputs +/// one-at-a-time, maintain a current state graph, and produce outputs. +/// +/// +/// Logic blocks are essentially statecharts that are created using the state +/// pattern. Each state is a self-contained class, record, or struct that +/// implements . +/// +/// +/// Input type. +/// State type. +/// Output type. +public interface ILogicBlockAsync + : ILogic< + TInput, + TState, + TOutput, + Func>, + Task, Func + > + where TInput : notnull + where TState : Logic< + TInput, + TState, + TOutput, + Func>, + Task, + Func + >.IStateLogic + where TOutput : notnull { + /// + /// Returns the initial state of the logic block. Implementations must + /// override this method to provide a valid initial state. + /// + /// Logic block context. + /// Initial state of the logic block. + TState GetInitialState( + Logic< + TInput, + TState, + TOutput, + Func>, + Task, + Func + >.IContext context + ); +} + /// /// /// A synchronous logic block. Logic blocks are machines that process inputs @@ -26,7 +77,8 @@ public abstract partial class LogicBlockAsync : Func>, Task, Func - > + >, + ILogicBlockAsync where TInput : notnull where TState : Logic< TInput, @@ -40,7 +92,7 @@ public abstract partial class LogicBlockAsync : /// /// The context provided to the states of the logic block. /// - public new Context Context { get; } + public new IContext Context { get; } /// /// Whether or not the logic block is processing inputs. @@ -53,20 +105,15 @@ public abstract partial class LogicBlockAsync : /// Creates a new asynchronous logic block. /// protected LogicBlockAsync() { - Context = new(this); + Context = new Context(this); _processTask.SetResult(default!); } /// public sealed override TState GetInitialState() => GetInitialState(Context); - /// - /// Returns the initial state of the logic block. Implementations must - /// override this method to provide a valid initial state. - /// - /// Logic block context. - /// Initial state of the logic block. - public abstract TState GetInitialState(Context context); + /// + public abstract TState GetInitialState(IContext context); internal override Task Process() { if (IsProcessing) { diff --git a/README.md b/README.md index 93a6b1e..e74307d 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,6 @@ Human-friendly state management for games and apps in C#. -Logic blocks borrow from [statecharts], [state machines][state-machines], and [blocs][bloc-pattern] to provide a flexible and easy-to-use API. - -Logic blocks allow developers to define self-contained states that read like ordinary code using the [state pattern][state-pattern] instead of requiring developers to write elaborate transition tables. Logic blocks are intended to be refactor-friendly and grow with your project from simple state machines to nested, hierarchical statecharts. - -> 🖼 Ever wondered what your code looks like? LogicBlocks includes an experimental generator that allows you to visualize your logic blocks as a state diagram! - ---

@@ -18,30 +12,33 @@ Logic blocks allow developers to define self-contained states that read like ord --- +Logic blocks borrow from [statecharts], [state machines][state-machines], and [blocs][bloc-pattern] to provide a flexible and easy-to-use API. + +Logic blocks allow developers to define self-contained states that read like ordinary code using the [state pattern][state-pattern] instead of requiring developers to write elaborate transition tables. Logic blocks are intended to be refactor-friendly and grow with your project from simple state machines to nested, hierarchical statecharts. + +> 🖼 Ever wondered what your code looks like? LogicBlocks includes an experimental generator that allows you to visualize your logic blocks as a state diagram! + **A logic block is a class that can receive inputs, maintain a state, and produce outputs.** How you design your states is up to you. Outputs allow logic block listeners to be informed about one-shot events that aren't persisted the way state is, allowing the logic block to influence the world around it without tight coupling. Additionally, logic block states can retrieve values shared across the entire logic block from the logic block's *blackboard*. Here is a minimal example. More ✨ advanced ✨ examples are linked below. ```csharp -namespace Chickensoft.LogicBlocks.Generator.Tests; - [StateMachine] -public class LightSwitch : - LogicBlock { - public override State GetInitialState(Context context) => +public class LightSwitch : LogicBlock { + public override State GetInitialState(IContext context) => new State.Off(context); public abstract record Input { public record Toggle : Input; } - public abstract record State(Context Context) : StateLogic(Context) { - public record On(Context Context) : State(Context), IGet { - public State On(Input.Toggle input) => new Off(Context); + public abstract record State(IContext Context) : StateLogic(Context) { + public record On(IContext Context) : State(Context), IGet { + State IGet.On(Input.Toggle input) => new Off(Context); } - public record Off(Context Context) : State(Context), IGet { - public State On(Input.Toggle input) => new On(Context); + public record Off(IContext Context) : State(Context), IGet { + State IGet.On(Input.Toggle input) => new On(Context); } } @@ -135,6 +132,10 @@ Logic blocks attempt to achieve the following goals: Logic blocks come with `Binding`, a utility class that provides a fluent API for monitoring states and outputs. Binding to a logic block is as simple as calling `myLogicBlock.Bind()`. +- 🧪 **Testable.** + + Logic blocks are easily tested using traditional mocking tools. You can mock the logic block, its context, and its bindings to unit-test your logic block states and logic block consumers in isolation. + ## 📦 Installation You can find the latest version of LogicBlocks on [nuget][logic-blocks-nuget]. @@ -178,7 +179,7 @@ Inside of the class, we need to define a base input type, state type, and output public class Heater : LogicBlock { public abstract record Input { } - public abstract record State(Context Context) : StateLogic(Context) { + public abstract record State(IContext Context) : StateLogic(Context) { } public abstract record Output { } @@ -225,16 +226,16 @@ We know our space heater will be in one of three states: `Off`, `Idle`, and `Hea We'll go ahead and write out the first two states, `Off` and `Idle`: ```csharp - public abstract record State(Context Context, double TargetTemp) + public abstract record State(IContext Context, double TargetTemp) : StateLogic(Context) { public record Off( - Context Context, double TargetTemp + IContext Context, double TargetTemp ) : State(Context, TargetTemp), IGet { public State On(Input.TurnOn input) => new Heating(Context, TargetTemp); } - public record Idle(Context Context, double TargetTemp) : + public record Idle(IContext Context, double TargetTemp) : State(Context, TargetTemp); } ``` @@ -251,7 +252,7 @@ In the case of `Off`, we only need to handle the `TurnOn` event. Input handlers IGet, IGet { - public Heating(Context context, double targetTemp) : base( + public Heating(IContext context, double targetTemp) : base( context, targetTemp ) { var tempSensor = context.Get(); @@ -300,7 +301,7 @@ public class Heater : Set(tempSensor); } - public override State GetInitialState(Context context) => + public override State GetInitialState(IContext context) => new State.Off(context, 72.0); } @@ -395,7 +396,7 @@ using Chickensoft.LogicBlocks.Generator; public partial class MyLogicBlock : LogicBlock { public abstract record Input { ... } - public abstract record State(Context Context) : StateLogic(Context) { ... } + public abstract record State(IContext Context) : StateLogic(Context) { ... } public abstract record Output { ... } public MyLogicBlock(IMyDependency dependency) { @@ -413,7 +414,7 @@ public partial class MyLogicBlock : } // Return the initial state by looking it up in the blackboard. - public override State GetInitialState(Context context) => + public override State GetInitialState(IContext context) => Context.Get(); } ``` @@ -476,7 +477,7 @@ using Chickensoft.LogicBlocks.Generator; public partial class MyLogicBlock : LogicBlock { public abstract record Input { ... } - public abstract record State(Context Context) : StateLogic(Context) { ... } + public abstract record State(IContext Context) : StateLogic(Context) { ... } public abstract record Output { ... } ... @@ -490,6 +491,122 @@ public partial class MyLogicBlock : } ``` +### 🧪 Testing + +You can mock a logic block, its bindings, and its context. + +- Mocking the context allows states to be tested in isolation. +- Mocking the logic block itself and its bindings allows you to simulate a logic block's behavior so that objects using a logic block can be tested in isolation (i.e., unit tests). + +#### Testing LogicBlock Consumers + +Imagine you have an object that uses a logic block called [`MyLogicBlock`](Chickensoft.LogicBlocks.Tests/test/fixtures/MyLogicBlock.cs). We'll keep the object simple for the sake of example. + +```csharp +public class MyObject { + public MyLogicBlock Logic { get; } + + public MyObject(MyLogicBlock logic) { + Logic = logic; + } + + // Method we want to test + public MyLogicBlock.State DoSomething() => + Logic.Input(new MyLogicBlock.Input.SomeInput()); +} +``` + +To write a unit test for `MyObject`, we need to mock its dependencies and then verify that it interacts with the dependencies in the way we expect. In this case, the only dependency is the logic block. We can mock it in the same way we mock other objects. + +```csharp +using Moq; +using Shouldly; +using Xunit; + +public class MyObjectTest { + [Fact] + public void DoSomethingDoesSomething() { + // Our unit test follows the AAA pattern: Arrange, Act, Assert. + // Or Setup, Execute, and Verify, if you prefer. Etc. + + // Setup + var logic = new Mock(); + var context = new Mock(); + + var myObject = new MyObject(logic.Object); + + // Create a state that we expect to be returned. + var expectedState = new MyLogicBlock.State.SomeState(context.Object); + + // Setup the mock of the logic block to return our expected state whenever + // it receives the input SomeInput. + logic.Setup(logic => logic.Input(It.IsAny())) + .Returns(expectedState); + + // Execute the method we want to test. + var result = myObject.DoSomething(); + + // Verify that method returned the correct value. + result.ShouldBe(expectedState); + + // Verify that the method invoked our logic block as expected. + logic.VerifyAll(); + } +} +``` + +#### Testing LogicBlock States + +We can also test that our logic block states work the way we intend them to work by mocking the context and expecting the state to call certain methods on it when certain inputs are received. + +Imagine we want to test the state `SomeState` on `MyLogicBlock`. + +For reference, here is the definition of `SomeState`. It receives `SomeInput`, outputs `SomeOutput`, and transitions to `SomeOtherState`. + +```csharp +// ... +public record SomeState(IContext Context) : State(Context), + IGet { + + public State On(Input.SomeInput input) { + Context.Output(new Output.SomeOutput()); + return new SomeOtherState(Context); + } + +} +// ... +``` + +To test it, we simply need to mock the logic block context and verify that it is called the way we expect it to be called. + +```csharp +using Moq; +using Shouldly; +using Xunit; + +public class SomeStateTest { + [Fact] + public void HandlesSomeInput() { + var context = new Mock(); + var state = new MyLogicBlock.State.SomeState(context.Object); + + // Expect our state to output SomeOutput when SomeInput is received. + context + .Setup(context => context.Output(new MyLogicBlock.Output.SomeOutput())); + + // Perform the action we are testing on our state. + var result = state.On(new MyLogicBlock.Input.SomeInput()); + + // Make sure the output we expected was produced by ensuring our mock + // context was called the same way we set it up. + context.VerifyAll(); + + // Make sure we got the next state. + result.ShouldBeOfType(); + } +} +``` + ## 🖼 Generating State Diagrams The LogicBlocks generator can generate UML code that can be used to visualize the statechart that your code represents.