diff --git a/.github/workflows/BuildAndTestOnPullRequests.yml b/.github/workflows/BuildAndTestOnPullRequests.yml index 6dc23c47..5f843ed1 100644 --- a/.github/workflows/BuildAndTestOnPullRequests.yml +++ b/.github/workflows/BuildAndTestOnPullRequests.yml @@ -4,35 +4,33 @@ on: push: branches: [ dev, master ] pull_request: - branches: [ dev , master ] + branches: [ dev, master ] jobs: Build_Stateless_solution: runs-on: windows-latest steps: - - uses: actions/checkout@v2 - with: - dotnet-version: 3.1.101 + - uses: actions/checkout@v3 - name: Install dependencies run: dotnet restore - name: Build Stateless solution - run: dotnet build Stateless.sln --configuration Release --no-restore + run: dotnet build Stateless.sln --configuration Release --no-restore - name: Test Stateless run: dotnet test --no-restore --no-build --configuration Release - name: Pack alpha version - if: github.ref == 'refs/heads/dev' && github.event_name == 'push' + if: github.ref == 'refs/heads/dev' && github.event_name == 'push' && github.repository == 'dotnet-state-machine/stateless' run: dotnet pack src\Stateless\Stateless.csproj --version-suffix dev-${{github.run_id}} --configuration Release - name: Publish alpha version - if: github.ref == 'refs/heads/dev' && github.event_name == 'push' + if: github.ref == 'refs/heads/dev' && github.event_name == 'push' && github.repository == 'dotnet-state-machine/stateless' run: dotnet nuget push src\Stateless\bin\Release\*.nupkg -s nuget.org --api-key ${{ secrets.NUGETAPIKEY }} - name: Pack Release version - if: github.ref == 'refs/heads/master' && github.event_name == 'push' + if: github.ref == 'refs/heads/master' && github.event_name == 'push' && github.repository == 'dotnet-state-machine/stateless' run: dotnet pack src\Stateless\Stateless.csproj --configuration Release - name: Publish Release version - if: github.ref == 'refs/heads/master' && github.event_name == 'push' + if: github.ref == 'refs/heads/master' && github.event_name == 'push' && github.repository == 'dotnet-state-machine/stateless' run: dotnet nuget push src\Stateless\bin\Release\*.nupkg -s nuget.org --api-key ${{ secrets.NUGETAPIKEY }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 07a1e42f..ee89471e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## 5.14.0 - 2023.11.14 +### Added + - Enable Source Link & Deterministic Builds [#501] + - Added optional `RetainSynchronizationContext` property [#519] + - Update example apps to `net6.0` [#520] + - Bump solution Visual Studio version to 2022 [#526] + - Remove obsolete TargetFrameworks [#524] + - Added `FireAsync(TriggerWithParameters, params object[])` overload [#536] +### Fixed + - `StateMachineInfo.InitialState.Transitions` throws if `AddRelationships` not called [#514] + - Trigger information is missing for `OnEntryFromAsync` [#511] + - Fixed typos & redundant parentheses [#512], [#521], [#522] + - Change mechanism for losing the synchronization context [#528] + - `InvalidOperationException` thrown from call to `FireAsync` [#532] + - Added missing guard function parameter support from `InternalTransitionAsyncIf` [#530] + - Using `PermitIf` on a state with substates leads to reentry [#544] + ## 5.13.0 - 2022.12.29 ### Added - Add method to get permitted triggers with parameter information [#494] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..18db56c8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing to Stateless + +If you're reading this page, thank you for considering making a contribution to Stateless! This project depends on the work of the community. As maintainers, we'll try our best to make bug fixes, review PRs, and respond to issues and feature requests. + +## Getting Started + +If you've found a bug, need a new feature or want to suggest a change, be sure to take a look through the [issues](https://github.com/dotnet-state-machine/stateless/issues?q=is%3Aissue) and [pull requests](https://github.com/dotnet-state-machine/stateless/pulls) in case it's been discussed before or is already in progress. + +If you've found a security vulnerability, please report it using the [Security Advisories](https://github.com/dotnet-state-machine/stateless/security/advisories) page. + +For anyone new to contributing to open source, there are some great guides to help you get started, such as [this one by freeCodeCamp.org](https://github.com/freeCodeCamp/how-to-contribute-to-open-source) and [this one from Open Source Guides](https://opensource.guide/how-to-contribute/). + +## General Guidance + +It's best to start by discussing a proposed change in an [issue](https://github.com/dotnet-state-machine/stateless/issues), be it a new issue you've created or an existing issue you're willing to help with. Let others know you're working on it. + +Check that your [fork is synced with the upsream repo](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) before you start working on a change, then create a branch from it and commit your changes to that branch. When you're ready for the change to be reviewed, create a pull request to merge your change back to the upstream `dotnet-state-machine:dev` branch. + +Please make sure your change meets the following criteria before raising a pull request: + +* Keep the change relevant to the specific issue being addressed; making changes that haven't been discussed could have unintended side effects. +* Add unit test coverage for your work; this not only helps to validate that your change works as described, it also acts as documentation and helps to defend against future regressions. +* Update the documentation! Help others benefit from your work by including guidance in the README. +* Be open and encouraging to constructive feedback; reviewers may ask for further changes or may want to discuss alternative approaches; the project will benefit from your patience and your willingness to engage with reviewers. + +## Other Ways to Contribute + +* Participate in open discussions, for instance by helping to answer questions or offering guidance to others. +* Improve the documentation, even something as small as fixing a typo or including a code snippet in the README; it all helps. +* Review a pull request; take a look through the [open pull requests](https://github.com/dotnet-state-machine/stateless/pulls) and offer constructive feedback. +* Boost the project; star the repository, mention it on social media, or link to it in your project's README. diff --git a/README.md b/README.md index 1a99a811..cca35def 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ phoneCall.Fire(Trigger.CallDialled); Assert.AreEqual(State.Ringing, phoneCall.State); ``` -This project, as well as the example above, was inspired by [Simple State Machine](http://simplestatemachine.codeplex.com/). +This project, as well as the example above, was inspired by [Simple State Machine (Archived)](https://web.archive.org/web/20170814020207/http://simplestatemachine.codeplex.com/). ## Features @@ -124,7 +124,7 @@ phoneCall.Configure(State.OffHook) Guard clauses within a state must be mutually exclusive (multiple guard clauses cannot be valid at the same time.) Substates can override transitions by respecifying them, however substates cannot disallow transitions that are allowed by the superstate. -The guard clauses will be evaluated whenever a trigger is fired. Guards should therefor be made side effect free. +The guard clauses will be evaluated whenever a trigger is fired. Guards should therefore be made side effect free. ### Parameterised Triggers @@ -225,11 +225,30 @@ await stateMachine.FireAsync(Trigger.Assigned); **Note:** while `StateMachine` may be used _asynchronously_, it remains single-threaded and may not be used _concurrently_ by multiple threads. +## Advanced Features ## + +### Retaining the SynchronizationContext ### +In specific situations where all handler methods must be invoked with the consumer's SynchronizationContext, set the _RetainSynchronizationContext_ property on creation: + +```csharp +var stateMachine = new StateMachine(initialState) +{ + RetainSynchronizationContext = true +}; +``` + +Setting this is vital within a Microsoft Orleans Grain for example, which requires the SynchronizationContext in order to make calls to other Grains. + ## Building Stateless runs on .NET 4.0+ and practically all modern .NET platforms by targeting .NET Standard 1.0 and .NET Standard2.0. Visual Studio 2017 or later is required to build the solution. +## Contributing + +We welcome contributions to this project. Check [CONTRIBUTING.md](CONTRIBUTING.md) for more info. + + ## Project Goals This page is an almost-complete description of Stateless, and its explicit aim is to remain minimal. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..984203e2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in Stateles, please report it via the [Security Advisories](https://github.com/dotnet-state-machine/stateless/security/advisories) page. Creating a security advistory will notify the project owners and allow them to assess it and take appropriate action to resolve it. diff --git a/Stateless.sln b/Stateless.sln index be9741b1..e98e1558 100644 --- a/Stateless.sln +++ b/Stateless.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29505.145 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.33213.308 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "global", "global", "{8DE7A8AE-D87D-46A0-9757-88BA4AF7EDA5}" ProjectSection(SolutionItems) = preProject @@ -36,6 +36,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TelephoneCallExample", "exa EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonExample", "example\JsonExample\JsonExample.csproj", "{809A7873-DD78-4D5D-A432-9718C929BECA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AlarmExample", "example\AlarmExample\AlarmExample.csproj", "{4E44B325-F791-4C24-872B-D1454DBBA30D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -66,6 +68,10 @@ Global {809A7873-DD78-4D5D-A432-9718C929BECA}.Debug|Any CPU.Build.0 = Debug|Any CPU {809A7873-DD78-4D5D-A432-9718C929BECA}.Release|Any CPU.ActiveCfg = Release|Any CPU {809A7873-DD78-4D5D-A432-9718C929BECA}.Release|Any CPU.Build.0 = Release|Any CPU + {4E44B325-F791-4C24-872B-D1454DBBA30D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4E44B325-F791-4C24-872B-D1454DBBA30D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4E44B325-F791-4C24-872B-D1454DBBA30D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4E44B325-F791-4C24-872B-D1454DBBA30D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -77,6 +83,7 @@ Global {19ABDDFE-C040-404E-897B-37BE6C248ED7} = {45C09CCA-6C76-4E10-B386-5D95A7610FE0} {5182CA95-8E6F-4D16-9790-8F7D1C5A9C87} = {45C09CCA-6C76-4E10-B386-5D95A7610FE0} {809A7873-DD78-4D5D-A432-9718C929BECA} = {45C09CCA-6C76-4E10-B386-5D95A7610FE0} + {4E44B325-F791-4C24-872B-D1454DBBA30D} = {45C09CCA-6C76-4E10-B386-5D95A7610FE0} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7A73ADDC-8150-4AFC-AAF0-BA8B4D7A94D7} diff --git a/example/AlarmExample/Alarm.cs b/example/AlarmExample/Alarm.cs new file mode 100644 index 00000000..063710cb --- /dev/null +++ b/example/AlarmExample/Alarm.cs @@ -0,0 +1,175 @@ +using AlarmExample; +using Stateless; +using System.Diagnostics; + +namespace AlarmExample +{ + /// + /// A sample class that implements an alarm as a state machine using Stateless + /// (https://github.com/dotnet-state-machine/stateless). + /// + /// It also shows one way that temporary states can be implemented with the use of + /// Timers. PreArmed, PreTriggered, Triggered, and ArmPaused are "temporary" states with + /// a configurable delay (i.e. to allow for an "arm delay"... a delay between Disarmed + /// and Armed). The Triggered state is also considered temporary, since if an alarm + /// sounds for a certain period of time and no-one Acknowledges it, the state machine + /// returns to the Armed state. + /// + /// Timers are triggered via OnEntry() and OnExit() methods. Transitions are written to + /// the Trace in order to show what happens. + /// + /// The included PNG file shows what the state flow looks like. + /// + /// + public partial class Alarm + { + /// + /// Moves the Alarm into the provided via the defined . + /// + /// The to execute on the current . + /// The new . + public AlarmState ExecuteTransition(AlarmCommand command) + { + if (_machine.CanFire(command)) + { + _machine.Fire(command); + } + else + { + throw new InvalidOperationException($"Cannot transition from {CurrentState} via {command}"); + } + + return CurrentState(); + } + + /// + /// The current of the alarm. + /// + public AlarmState CurrentState() + { + if (_machine != null) + return _machine.State; + else + throw new InvalidOperationException("Alarm hasn't been configured yet."); + } + + /// + /// Defines whether the has been configured. + /// + public bool IsConfigured { get; private set; } + + /// + /// Returns whether the provided command is a valid transition from the Current State. + /// + /// + /// + public bool CanFireCommand(AlarmCommand command) + { + return _machine.CanFire(command); + } + + /// + /// Default constructor. + /// + /// The time (in seconds) the alarm will spend in the + /// Prearmed status before continuing to the Armed status (if not transitioned to + /// Disarmed via Disarm). + /// The time (in seconds) the alarm will spend in the + /// ArmPaused status before returning to Armed (if not transitioned to Triggered + /// via Trigger). + /// The time (in seconds) the alarm will spend in the + /// PreTriggered status before continuing to the Triggered status (if not + /// transitioned to Disarmed via Disarm). + /// The time (in seconds) the alarm will spend in the + /// Triggered status before returning to the Armed status (if not transitioned to + /// Disarmed via Disarm). + public Alarm(int armDelay, int pauseDelay, int triggerDelay, int triggerTimeOut) + { + _machine = new StateMachine(AlarmState.Undefined); + + preArmTimer = new System.Timers .Timer(armDelay * 1000) { AutoReset = false, Enabled = false }; + preArmTimer.Elapsed += TimeoutTimerElapsed; + pauseTimer = new System.Timers.Timer(pauseDelay * 1000) { AutoReset = false, Enabled = false }; + pauseTimer.Elapsed += TimeoutTimerElapsed; + triggerDelayTimer = new System.Timers.Timer(triggerDelay * 1000) { AutoReset = false, Enabled = false }; + triggerDelayTimer.Elapsed += TimeoutTimerElapsed; + triggerTimeOutTimer = new System.Timers.Timer(triggerTimeOut * 1000) { AutoReset = false, Enabled = false }; + triggerTimeOutTimer.Elapsed += TimeoutTimerElapsed; + + _machine.OnTransitioned(OnTransition); + + _machine.Configure(AlarmState.Undefined) + .Permit(AlarmCommand.Startup, AlarmState.Disarmed) + .OnExit(() => IsConfigured = true); + + _machine.Configure(AlarmState.Disarmed) + .Permit(AlarmCommand.Arm, AlarmState.Prearmed); + + _machine.Configure(AlarmState.Armed) + .Permit(AlarmCommand.Disarm, AlarmState.Disarmed) + .Permit(AlarmCommand.Trigger, AlarmState.PreTriggered) + .Permit(AlarmCommand.Pause, AlarmState.ArmPaused); + + _machine.Configure(AlarmState.Prearmed) + .OnEntry(() => ConfigureTimer(true, preArmTimer, "Pre-arm")) + .OnExit(() => ConfigureTimer(false, preArmTimer, "Pre-arm")) + .Permit(AlarmCommand.TimeOut, AlarmState.Armed) + .Permit(AlarmCommand.Disarm, AlarmState.Disarmed); + + _machine.Configure(AlarmState.ArmPaused) + .OnEntry(() => ConfigureTimer(true, pauseTimer, "Pause delay")) + .OnExit(() => ConfigureTimer(false, pauseTimer, "Pause delay")) + .Permit(AlarmCommand.TimeOut, AlarmState.Armed) + .Permit(AlarmCommand.Trigger, AlarmState.PreTriggered); + + _machine.Configure(AlarmState.Triggered) + .OnEntry(() => ConfigureTimer(true, triggerTimeOutTimer, "Trigger timeout")) + .OnExit(() => ConfigureTimer(false, triggerTimeOutTimer, "Trigger timeout")) + .Permit(AlarmCommand.TimeOut, AlarmState.Armed) + .Permit(AlarmCommand.Acknowledge, AlarmState.Acknowledged); + + _machine.Configure(AlarmState.PreTriggered) + .OnEntry(() => ConfigureTimer(true, triggerDelayTimer, "Trigger delay")) + .OnExit(() => ConfigureTimer(false, triggerDelayTimer, "Trigger delay")) + .Permit(AlarmCommand.TimeOut, AlarmState.Triggered) + .Permit(AlarmCommand.Disarm, AlarmState.Disarmed); + + _machine.Configure(AlarmState.Acknowledged) + .Permit(AlarmCommand.Disarm, AlarmState.Disarmed); + + _machine.Fire(AlarmCommand.Startup); + } + + private void TimeoutTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e) + { + _machine.Fire(AlarmCommand.TimeOut); + } + + private void ConfigureTimer(bool active, System.Timers.Timer timer, string timerName) + { + if (timer != null) + if (active) + { + timer.Start(); + Trace.WriteLine($"{timerName} started."); + } + else + { + timer.Stop(); + Trace.WriteLine($"{timerName} cancelled."); + } + } + + private void OnTransition(StateMachine.Transition transition) + { + Trace.WriteLine($"Transitioned from {transition.Source} to " + + $"{transition.Destination} via {transition.Trigger}."); + } + + private StateMachine _machine; + private System.Timers.Timer? preArmTimer; + private System.Timers.Timer? pauseTimer; + private System.Timers.Timer? triggerDelayTimer; + private System.Timers.Timer? triggerTimeOutTimer; + } +} \ No newline at end of file diff --git a/example/AlarmExample/AlarmCommand.cs b/example/AlarmExample/AlarmCommand.cs new file mode 100644 index 00000000..0439372a --- /dev/null +++ b/example/AlarmExample/AlarmCommand.cs @@ -0,0 +1,13 @@ +namespace AlarmExample +{ + public enum AlarmCommand + { + Startup, + Arm, + Disarm, + Trigger, + Acknowledge, + Pause, + TimeOut + } +} diff --git a/example/AlarmExample/AlarmExample.csproj b/example/AlarmExample/AlarmExample.csproj new file mode 100644 index 00000000..4d45f646 --- /dev/null +++ b/example/AlarmExample/AlarmExample.csproj @@ -0,0 +1,14 @@ + + + + Exe + net7.0 + enable + enable + + + + + + + diff --git a/example/AlarmExample/AlarmState.cs b/example/AlarmExample/AlarmState.cs new file mode 100644 index 00000000..5c6c5a59 --- /dev/null +++ b/example/AlarmExample/AlarmState.cs @@ -0,0 +1,14 @@ +namespace AlarmExample +{ + public enum AlarmState + { + Undefined, + Disarmed, + Prearmed, + Armed, + Triggered, + ArmPaused, + PreTriggered, + Acknowledged + } +} diff --git a/example/AlarmExample/Program.cs b/example/AlarmExample/Program.cs new file mode 100644 index 00000000..49a01a44 --- /dev/null +++ b/example/AlarmExample/Program.cs @@ -0,0 +1,115 @@ +namespace AlarmExample +{ + /// + /// A simple Console Application that allows for interactive input + /// to test the implemented as a Stateless state + /// machine. + /// + internal class Program + { + static Alarm? _alarm; + + static void Main(string[] args) + { + _alarm = new Alarm(10, 10, 10, 10); + + string input = ""; + + WriteHeader(); + + while (input != "q") + { + Console.Write("> "); + + input = Console.ReadLine(); + + if (!string.IsNullOrWhiteSpace(input)) + switch (input.Split(" ")[0]) + { + case "q": + Console.WriteLine("Exiting..."); + break; + case "fire": + WriteFire(input); + break; + case "canfire": + WriteCanFire(); + break; + case "state": + WriteState(); + break; + case "h": + case "help": + WriteHelp(); + break; + case "c": + case "clear": + Console.Clear(); + WriteHeader(); + break; + default: + Console.WriteLine("Invalid command. Type 'h' or 'help' for valid commands."); + break; + } + } + } + + static void WriteHelp() + { + Console.WriteLine("Valid commands:"); + Console.WriteLine("q - Exit"); + Console.WriteLine("fire - Tries to fire the provided commands"); + Console.WriteLine("canfire - Returns a list of fireable commands"); + Console.WriteLine("state - Returns the current state"); + Console.WriteLine("c / clear - Clear the window"); + Console.WriteLine("h / help - Show this again"); + } + + static void WriteHeader() + { + Console.WriteLine("Stateless-based alarm test application:"); + Console.WriteLine("---------------------------------------"); + Console.WriteLine(""); + } + + static void WriteCanFire() + { + foreach (AlarmCommand command in (AlarmCommand[])Enum.GetValues(typeof(AlarmCommand))) + if (_alarm != null && _alarm.CanFireCommand(command)) + Console.WriteLine($"{Enum.GetName(typeof(AlarmCommand), command)}"); + } + + static void WriteState() + { + if(_alarm != null ) + Console.WriteLine($"The current state is {Enum.GetName(typeof(AlarmState), _alarm.CurrentState())}"); + } + + static void WriteFire(string input) + { + if (input.Split(" ").Length == 2) + { + try + { + if (Enum.TryParse(input.Split(" ")[1], out AlarmCommand command)) + { + if (_alarm != null) + _alarm.ExecuteTransition(command); + } + else + { + Console.WriteLine($"{input.Split(" ")[1]} is not a valid AlarmCommand."); + } + } + catch (InvalidOperationException ex) + { + Console.WriteLine($"{input.Split(" ")[1]} is not a valid AlarmCommand to the current state."); + } + } + else + { + Console.WriteLine("fire requires you to specify the command you want to fire."); + } + } + } +} \ No newline at end of file diff --git a/example/AlarmExample/StateDiagram.png b/example/AlarmExample/StateDiagram.png new file mode 100644 index 00000000..752793d7 Binary files /dev/null and b/example/AlarmExample/StateDiagram.png differ diff --git a/example/BugTrackerExample/BugTrackerExample.csproj b/example/BugTrackerExample/BugTrackerExample.csproj index 84cf0d5b..7185d83e 100644 --- a/example/BugTrackerExample/BugTrackerExample.csproj +++ b/example/BugTrackerExample/BugTrackerExample.csproj @@ -1,7 +1,7 @@  - netcoreapp2.1 + net6.0 BugTrackerExample Exe BugTrackerExample diff --git a/example/JsonExample/JsonExample.csproj b/example/JsonExample/JsonExample.csproj index 289d4eb7..2dd5cb6b 100644 --- a/example/JsonExample/JsonExample.csproj +++ b/example/JsonExample/JsonExample.csproj @@ -2,7 +2,7 @@ Exe - netcoreapp2.1 + net6.0 diff --git a/example/JsonExample/Member.cs b/example/JsonExample/Member.cs index a3db89d8..4bc86d3d 100644 --- a/example/JsonExample/Member.cs +++ b/example/JsonExample/Member.cs @@ -81,7 +81,7 @@ public static Member FromJson(string jsonString) public bool Equals(Member anotherMember) { - return ((State == anotherMember.State) && (Name == anotherMember.Name)); + return State == anotherMember.State && Name == anotherMember.Name; } } diff --git a/example/OnOffExample/OnOffExample.csproj b/example/OnOffExample/OnOffExample.csproj index 725615a9..94652a1f 100644 --- a/example/OnOffExample/OnOffExample.csproj +++ b/example/OnOffExample/OnOffExample.csproj @@ -1,7 +1,7 @@  - netcoreapp2.1 + net6.0 OnOffExample Exe OnOffExample diff --git a/example/TelephoneCallExample/TelephoneCallExample.csproj b/example/TelephoneCallExample/TelephoneCallExample.csproj index 6a798040..666893eb 100644 --- a/example/TelephoneCallExample/TelephoneCallExample.csproj +++ b/example/TelephoneCallExample/TelephoneCallExample.csproj @@ -1,7 +1,7 @@  - netcoreapp2.1 + net6.0 TelephoneCallExample Exe TelephoneCallExample diff --git a/src/Stateless/EntryActionBehaviour.cs b/src/Stateless/EntryActionBehaviour.cs index 2289fc99..8023cebc 100644 --- a/src/Stateless/EntryActionBehaviour.cs +++ b/src/Stateless/EntryActionBehaviour.cs @@ -40,7 +40,7 @@ public override Task ExecuteAsync(Transition transition, object[] args) public class SyncFrom : Sync { - internal TTriggerType Trigger { get; private set; } + internal TTriggerType Trigger { get; } public SyncFrom(TTriggerType trigger, Action action, Reflection.InvocationInfo description) : base(action, description) @@ -82,6 +82,35 @@ public override Task ExecuteAsync(Transition transition, object[] args) return _action(transition, args); } } + + public class AsyncFrom : Async + { + internal TTriggerType Trigger { get; } + + public AsyncFrom(TTriggerType trigger, Func action, Reflection.InvocationInfo description) + : base(action, description) + { + Trigger = trigger; + } + + public override void Execute(Transition transition, object[] args) + { + if (transition.Trigger.Equals(Trigger)) + { + base.Execute(transition, args); + } + } + + public override Task ExecuteAsync(Transition transition, object[] args) + { + if (transition.Trigger.Equals(Trigger)) + { + return base.ExecuteAsync(transition, args); + } + + return TaskResult.Done; + } + } } } } diff --git a/src/Stateless/Graph/StateGraph.cs b/src/Stateless/Graph/StateGraph.cs index bf01ddff..74a2d9ca 100644 --- a/src/Stateless/Graph/StateGraph.cs +++ b/src/Stateless/Graph/StateGraph.cs @@ -69,7 +69,7 @@ public string ToGraph(GraphStyleBase style) // Next process all non-cluster states foreach (var state in States.Values) { - if ((state is SuperState) || (state is Decision) || (state.SuperState != null)) + if (state is SuperState || state is Decision || state.SuperState != null) continue; dirgraphText += style.FormatOneState(state).Replace("\n", System.Environment.NewLine); } @@ -115,8 +115,8 @@ void ProcessOnEntryFrom(StateMachineInfo machineInfo) // Does it have any incoming transitions that specify that trigger? foreach (var transit in state.Arriving) { - if ((transit.ExecuteEntryExitActions) - && (transit.Trigger.UnderlyingTrigger.ToString() == entryAction.FromTrigger)) + if (transit.ExecuteEntryExitActions + && transit.Trigger.UnderlyingTrigger.ToString() == entryAction.FromTrigger) { transit.DestinationEntryActions.Add(entryAction); } @@ -208,7 +208,7 @@ void AddSingleStates(StateMachineInfo machineInfo) /// void AddSuperstates(StateMachineInfo machineInfo) { - foreach (var stateInfo in machineInfo.States.Where(sc => (sc.Substates?.Count() > 0) && (sc.Superstate == null))) + foreach (var stateInfo in machineInfo.States.Where(sc => sc.Substates?.Count() > 0 && sc.Superstate == null)) { SuperState state = new SuperState(stateInfo); States[stateInfo.UnderlyingState.ToString()] = state; diff --git a/src/Stateless/Graph/UmlDotGraphStyle.cs b/src/Stateless/Graph/UmlDotGraphStyle.cs index 0d47b17d..b21f0599 100644 --- a/src/Stateless/Graph/UmlDotGraphStyle.cs +++ b/src/Stateless/Graph/UmlDotGraphStyle.cs @@ -33,7 +33,7 @@ public override string FormatOneCluster(SuperState stateInfo) StringBuilder label = new StringBuilder($"{sourceName}"); - if ((stateInfo.EntryActions.Count > 0) || (stateInfo.ExitActions.Count > 0)) + if (stateInfo.EntryActions.Count > 0 || stateInfo.ExitActions.Count > 0) { label.Append("\\n----------"); label.Append(string.Concat(stateInfo.EntryActions.Select(act => "\\nentry / " + act))); @@ -62,7 +62,7 @@ public override string FormatOneCluster(SuperState stateInfo) /// public override string FormatOneState(State state) { - if ((state.EntryActions.Count == 0) && (state.ExitActions.Count == 0)) + if (state.EntryActions.Count == 0 && state.ExitActions.Count == 0) return $"\"{state.StateName}\" [label=\"{state.StateName}\"];\n"; string f = $"\"{state.StateName}\" [label=\"{state.StateName}|"; diff --git a/src/Stateless/GuardCondition.cs b/src/Stateless/GuardCondition.cs index 775588b3..1a4b2320 100644 --- a/src/Stateless/GuardCondition.cs +++ b/src/Stateless/GuardCondition.cs @@ -10,7 +10,7 @@ internal class GuardCondition /// /// Constructor that takes in a guard with no argument. - /// This is needed because we wrap the no-arg guard with a lamba and therefore method description won't match what was origianlly passed in. + /// This is needed because we wrap the no-arg guard with a lambda and therefore method description won't match what was originally passed in. /// We need to preserve the method description before wrapping so Reflection methods will work. /// /// No Argument Guard Condition diff --git a/src/Stateless/InternalTriggerBehaviour.cs b/src/Stateless/InternalTriggerBehaviour.cs index fe6dd927..3966a945 100644 --- a/src/Stateless/InternalTriggerBehaviour.cs +++ b/src/Stateless/InternalTriggerBehaviour.cs @@ -20,8 +20,7 @@ public override bool ResultsInTransitionFrom(TState source, object[] args, out T return false; } - - public class Sync: InternalTriggerBehaviour + public class Sync : InternalTriggerBehaviour { public Action InternalAction { get; } @@ -29,6 +28,7 @@ public class Sync: InternalTriggerBehaviour { InternalAction = internalAction; } + public override void Execute(Transition transition, object[] args) { InternalAction(transition, args); @@ -45,7 +45,13 @@ public class Async : InternalTriggerBehaviour { readonly Func InternalAction; - public Async(TTrigger trigger, Func guard,Func internalAction, string guardDescription = null) : base(trigger, new TransitionGuard(guard, guardDescription)) + public Async(TTrigger trigger, Func guard, Func internalAction, string guardDescription = null) : base(trigger, new TransitionGuard(guard, guardDescription)) + { + InternalAction = internalAction; + } + + [Obsolete] + public Async(TTrigger trigger, Func guard, Func internalAction, string guardDescription = null) : base(trigger, new TransitionGuard(guard, guardDescription)) { InternalAction = internalAction; } @@ -61,10 +67,7 @@ public override Task ExecuteAsync(Transition transition, object[] args) { return InternalAction(transition, args); } - } - - } } -} \ No newline at end of file +} diff --git a/src/Stateless/OnTransitionedEvent.cs b/src/Stateless/OnTransitionedEvent.cs index c8dbbc2e..4ede1525 100644 --- a/src/Stateless/OnTransitionedEvent.cs +++ b/src/Stateless/OnTransitionedEvent.cs @@ -10,7 +10,7 @@ class OnTransitionedEvent { event Action _onTransitioned; readonly List> _onTransitionedAsync = new List>(); - + public void Invoke(Transition transition) { if (_onTransitionedAsync.Count != 0) @@ -22,12 +22,12 @@ public void Invoke(Transition transition) } #if TASKS - public async Task InvokeAsync(Transition transition) + public async Task InvokeAsync(Transition transition, bool retainSynchronizationContext) { _onTransitioned?.Invoke(transition); foreach (var callback in _onTransitionedAsync) - await callback(transition).ConfigureAwait(false); + await callback(transition).ConfigureAwait(retainSynchronizationContext); } #endif diff --git a/src/Stateless/ParameterConversionResources.Designer.cs b/src/Stateless/ParameterConversionResources.Designer.cs index 840edf10..4d39085f 100644 --- a/src/Stateless/ParameterConversionResources.Designer.cs +++ b/src/Stateless/ParameterConversionResources.Designer.cs @@ -37,7 +37,7 @@ internal ParameterConversionResources() { internal static global::System.Resources.ResourceManager ResourceManager { get { if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Stateless.ParameterConversionResources", typeof(ParameterConversionResources).GetAssembly()); + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Stateless.ParameterConversionResources", typeof(ParameterConversionResources).Assembly); resourceMan = temp; } return resourceMan; diff --git a/src/Stateless/Reflection/ActionInfo.cs b/src/Stateless/Reflection/ActionInfo.cs index 7f05d5c9..cf0391a9 100644 --- a/src/Stateless/Reflection/ActionInfo.cs +++ b/src/Stateless/Reflection/ActionInfo.cs @@ -18,13 +18,16 @@ public class ActionInfo internal static ActionInfo Create(StateMachine.EntryActionBehavior entryAction) { StateMachine.EntryActionBehavior.SyncFrom syncFrom = entryAction as StateMachine.EntryActionBehavior.SyncFrom; - if (syncFrom != null) return new ActionInfo(entryAction.Description, syncFrom.Trigger.ToString()); - else - return new ActionInfo(entryAction.Description, null); + + StateMachine.EntryActionBehavior.AsyncFrom asyncFrom = entryAction as StateMachine.EntryActionBehavior.AsyncFrom; + if (asyncFrom != null) + return new ActionInfo(entryAction.Description, asyncFrom.Trigger.ToString()); + + return new ActionInfo(entryAction.Description, null); } - + /// /// Constructor /// diff --git a/src/Stateless/Reflection/DynamicTransitionInfo.cs b/src/Stateless/Reflection/DynamicTransitionInfo.cs index 0b6d924c..a8179256 100644 --- a/src/Stateless/Reflection/DynamicTransitionInfo.cs +++ b/src/Stateless/Reflection/DynamicTransitionInfo.cs @@ -59,7 +59,7 @@ public void Add(TState destinationState, string criterion) public class DynamicTransitionInfo : TransitionInfo { /// - /// Gets method informtion for the destination state selector. + /// Gets method information for the destination state selector. /// public InvocationInfo DestinationStateSelectorDescription { get; private set; } diff --git a/src/Stateless/Reflection/FixedTransitionInfo.cs b/src/Stateless/Reflection/FixedTransitionInfo.cs index 72530186..0aed3ec9 100644 --- a/src/Stateless/Reflection/FixedTransitionInfo.cs +++ b/src/Stateless/Reflection/FixedTransitionInfo.cs @@ -14,7 +14,7 @@ internal static FixedTransitionInfo Create(StateMachine() : behaviour.Guard.Conditions.Select(c => c.MethodDescription) }; diff --git a/src/Stateless/Reflection/IgnoredTransitionInfo.cs b/src/Stateless/Reflection/IgnoredTransitionInfo.cs index ca41bbb1..6e669950 100644 --- a/src/Stateless/Reflection/IgnoredTransitionInfo.cs +++ b/src/Stateless/Reflection/IgnoredTransitionInfo.cs @@ -13,7 +13,7 @@ internal static IgnoredTransitionInfo Create(StateMachine() : behaviour.Guard.Conditions.Select(c => c.MethodDescription) }; diff --git a/src/Stateless/Reflection/InvocationInfo.cs b/src/Stateless/Reflection/InvocationInfo.cs index 4693aae9..9f0270fa 100644 --- a/src/Stateless/Reflection/InvocationInfo.cs +++ b/src/Stateless/Reflection/InvocationInfo.cs @@ -24,7 +24,7 @@ public enum Timing internal static InvocationInfo Create(Delegate method, string description, Timing timing = Timing.Synchronous) { - return new InvocationInfo(method?.TryGetMethodName(), description, timing); + return new InvocationInfo(method?.Method?.Name, description, timing); } /// @@ -72,6 +72,6 @@ public string Description /// /// Returns true if the method is invoked asynchronously. /// - public bool IsAsync => (_timing == Timing.Asynchronous); + public bool IsAsync => _timing == Timing.Asynchronous; } } diff --git a/src/Stateless/Reflection/StateInfo.cs b/src/Stateless/Reflection/StateInfo.cs index d18cab1e..340166e3 100644 --- a/src/Stateless/Reflection/StateInfo.cs +++ b/src/Stateless/Reflection/StateInfo.cs @@ -51,18 +51,18 @@ internal static void AddRelationships(StateInfo info, StateMac foreach (var triggerBehaviours in stateRepresentation.TriggerBehaviours) { // First add all the deterministic transitions - foreach (var item in triggerBehaviours.Value.Where(behaviour => (behaviour is StateMachine.TransitioningTriggerBehaviour))) + foreach (var item in triggerBehaviours.Value.Where(behaviour => behaviour is StateMachine.TransitioningTriggerBehaviour)) { var destinationInfo = lookupState(((StateMachine.TransitioningTriggerBehaviour)item).Destination); fixedTransitions.Add(FixedTransitionInfo.Create(item, destinationInfo)); } - foreach (var item in triggerBehaviours.Value.Where(behaviour => (behaviour is StateMachine.ReentryTriggerBehaviour))) + foreach (var item in triggerBehaviours.Value.Where(behaviour => behaviour is StateMachine.ReentryTriggerBehaviour)) { var destinationInfo = lookupState(((StateMachine.ReentryTriggerBehaviour)item).Destination); fixedTransitions.Add(FixedTransitionInfo.Create(item, destinationInfo)); } //Then add all the internal transitions - foreach (var item in triggerBehaviours.Value.Where(behaviour => (behaviour is StateMachine.InternalTriggerBehaviour))) + foreach (var item in triggerBehaviours.Value.Where(behaviour => behaviour is StateMachine.InternalTriggerBehaviour)) { var destinationInfo = lookupState(stateRepresentation.UnderlyingState); fixedTransitions.Add(FixedTransitionInfo.Create(item, destinationInfo)); @@ -136,14 +136,21 @@ private void AddRelationships( public IEnumerable DeactivateActions { get; private set; } /// - /// Actions that are defined to be exectuted on state-exit. + /// Actions that are defined to be executed on state-exit. /// public IEnumerable ExitActions { get; private set; } /// /// Transitions defined for this state. /// - public IEnumerable Transitions { get { return FixedTransitions.Concat(DynamicTransitions); } } + public IEnumerable Transitions + { + get { + return FixedTransitions == null // A quick way to check if AddRelationships has been called. + ? null + : FixedTransitions.Concat(DynamicTransitions); + } + } /// /// Transitions defined for this state. diff --git a/src/Stateless/ReflectionExtensions.cs b/src/Stateless/ReflectionExtensions.cs deleted file mode 100644 index 191c520d..00000000 --- a/src/Stateless/ReflectionExtensions.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Reflection; - -namespace Stateless -{ - internal static class ReflectionExtensions - { - public static Assembly GetAssembly(this Type type) - { -#if PORTABLE_REFLECTION - return type.GetTypeInfo().Assembly; -#else - return type.Assembly; -#endif - } - public static bool IsAssignableFrom(this Type type, Type otherType) - { -#if PORTABLE_REFLECTION - return type.GetTypeInfo().IsAssignableFrom(otherType.GetTypeInfo()); -#else - return type.IsAssignableFrom(otherType); -#endif - } - - - - - - /// - /// Convenience method to get for different PCL profiles. - /// - /// Delegate whose method info is desired - /// Null if is null, otherwise . - public static MethodInfo TryGetMethodInfo(this Delegate del) - { -#if PORTABLE_REFLECTION - return del?.GetMethodInfo(); -#else - return del?.Method; -#endif - } - - /// - /// Convenience method to get method name for different PCL profiles. - /// - /// Delegate whose method name is desired - /// Null if is null, otherwise . - public static string TryGetMethodName(this Delegate del) - { - return TryGetMethodInfo(del)?.Name; - } - } -} \ No newline at end of file diff --git a/src/Stateless/StateConfiguration.Async.cs b/src/Stateless/StateConfiguration.Async.cs index 6a89fbc3..53a8c918 100644 --- a/src/Stateless/StateConfiguration.Async.cs +++ b/src/Stateless/StateConfiguration.Async.cs @@ -12,18 +12,95 @@ public partial class StateConfiguration /// /// Add an internal transition to the state machine. An internal action does not cause the Exit and Entry actions to be triggered, and does not change the state of the state machine /// - /// + /// + /// The accepted trigger /// Function that must return true in order for the trigger to be accepted. - /// + /// The asynchronous action performed by the internal transition /// - public StateConfiguration InternalTransitionAsyncIf(TTrigger trigger, Func guard, Func entryAction) + [Obsolete("Use InternalTransitionAsyncIf(TTrigger, Func, Func) instead.")] + public StateConfiguration InternalTransitionAsyncIf(TTrigger trigger, Func guard, Func internalAction) { - if (entryAction == null) throw new ArgumentNullException(nameof(entryAction)); + if (internalAction == null) throw new ArgumentNullException(nameof(internalAction)); + + _representation.AddTriggerBehaviour(new InternalTriggerBehaviour.Async(trigger, guard, (t, args) => internalAction(t))); + return this; + } + + /// + /// Add an internal transition to the state machine. An internal action does not cause the Exit and Entry actions to be triggered, and does not change the state of the state machine + /// + /// + /// The accepted trigger + /// Function that must return true in order for the trigger to be accepted. + /// The asynchronous action performed by the internal transition + /// + [Obsolete("Use InternalTransitionAsyncIf(TriggerWithParameters, Func, Func) instead.")] + public StateConfiguration InternalTransitionAsyncIf(TriggerWithParameters trigger, Func guard, Func internalAction) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (internalAction == null) throw new ArgumentNullException(nameof(internalAction)); + + _representation.AddTriggerBehaviour(new InternalTriggerBehaviour.Async(trigger.Trigger, guard, (t, args) => internalAction(ParameterConversion.Unpack(args, 0), t))); + return this; + } + + /// + /// Add an internal transition to the state machine. An internal action does not cause the Exit and Entry actions to be triggered, and does not change the state of the state machine + /// + /// + /// + /// The accepted trigger + /// Function that must return true in order for the trigger to be accepted. + /// The asynchronous action performed by the internal transition + /// + [Obsolete("Use InternalTransitionAsyncIf(TriggerWithParameters, Func, Func) instead.")] + public StateConfiguration InternalTransitionAsyncIf(TriggerWithParameters trigger, Func guard, Func internalAction) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (internalAction == null) throw new ArgumentNullException(nameof(internalAction)); + + _representation.AddTriggerBehaviour(new InternalTriggerBehaviour.Async(trigger.Trigger, guard, (t, args) => internalAction( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1), t))); + return this; + } + + /// + /// Add an internal transition to the state machine. An internal action does not cause the Exit and Entry actions to be triggered, and does not change the state of the state machine + /// + /// + /// + /// + /// The accepted trigger + /// Function that must return true in order for the trigger to be accepted. + /// The asynchronous action performed by the internal transition + /// + [Obsolete("Use InternalTransitionAsyncIf(TriggerWithParameters, Func, Func) instead.")] + public StateConfiguration InternalTransitionAsyncIf(TriggerWithParameters trigger, Func guard, Func internalAction) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + if (internalAction == null) throw new ArgumentNullException(nameof(internalAction)); - _representation.AddTriggerBehaviour(new InternalTriggerBehaviour.Async(trigger, guard, (t, args) => entryAction(t))); + _representation.AddTriggerBehaviour(new InternalTriggerBehaviour.Async(trigger.Trigger, guard, (t, args) => internalAction( + ParameterConversion.Unpack(args, 0), + ParameterConversion.Unpack(args, 1), + ParameterConversion.Unpack(args, 2), t))); return this; } + /// + /// Add an internal transition to the state machine. An internal action does not cause the Exit and Entry actions to be triggered, and does not change the state of the state machine + /// + /// + /// The accepted trigger + /// The asynchronous action performed by the internal transition + /// + [Obsolete("Use InternalTransitionAsync(TTrigger, Func) instead.")] + public StateConfiguration InternalTransitionAsync(TTrigger trigger, Func internalAction) + { + return InternalTransitionAsyncIf(trigger, () => true, internalAction); + } + /// /// Add an internal transition to the state machine. An internal action does not cause the Exit and Entry actions to be triggered, and does not change the state of the state machine /// @@ -35,23 +112,22 @@ public StateConfiguration InternalTransitionAsyncIf(TTrigger trigger, Func { if (internalAction == null) throw new ArgumentNullException(nameof(internalAction)); - _representation.AddTriggerBehaviour(new InternalTriggerBehaviour.Async(trigger, guard, (t, args) => internalAction())); + _representation.AddTriggerBehaviour(new InternalTriggerBehaviour.Async(trigger, t => guard(), (t, args) => internalAction())); return this; } /// /// Add an internal transition to the state machine. An internal action does not cause the Exit and Entry actions to be triggered, and does not change the state of the state machine /// - /// /// The accepted trigger /// Function that must return true in order for the trigger to be accepted. /// The asynchronous action performed by the internal transition /// - public StateConfiguration InternalTransitionAsyncIf(TTrigger trigger, Func guard, Func internalAction) + public StateConfiguration InternalTransitionAsyncIf(TTrigger trigger, Func guard, Func internalAction) { if (internalAction == null) throw new ArgumentNullException(nameof(internalAction)); - _representation.AddTriggerBehaviour(new InternalTriggerBehaviour.Async(trigger, guard, (t, args) => internalAction(t))); + _representation.AddTriggerBehaviour(new InternalTriggerBehaviour.Async(trigger, t => guard(), (t, args) => internalAction(t))); return this; } @@ -63,12 +139,12 @@ public StateConfiguration InternalTransitionAsyncIf(TTrigger trigger, Fun /// Function that must return true in order for the trigger to be accepted. /// The asynchronous action performed by the internal transition /// - public StateConfiguration InternalTransitionAsyncIf(TriggerWithParameters trigger, Func guard, Func internalAction) + public StateConfiguration InternalTransitionAsyncIf(TriggerWithParameters trigger, Func guard, Func internalAction) { if (trigger == null) throw new ArgumentNullException(nameof(trigger)); if (internalAction == null) throw new ArgumentNullException(nameof(internalAction)); - _representation.AddTriggerBehaviour(new InternalTriggerBehaviour.Async(trigger.Trigger, guard, (t, args) => internalAction(ParameterConversion.Unpack(args, 0), t))); + _representation.AddTriggerBehaviour(new InternalTriggerBehaviour.Async(trigger.Trigger, TransitionGuard.ToPackedGuard(guard), (t, args) => internalAction(ParameterConversion.Unpack(args, 0), t))); return this; } @@ -81,12 +157,12 @@ public StateConfiguration InternalTransitionAsyncIf(TriggerWithParameters /// Function that must return true in order for the trigger to be accepted. /// The asynchronous action performed by the internal transition /// - public StateConfiguration InternalTransitionAsyncIf(TriggerWithParameters trigger, Func guard, Func internalAction) + public StateConfiguration InternalTransitionAsyncIf(TriggerWithParameters trigger, Func guard, Func internalAction) { if (trigger == null) throw new ArgumentNullException(nameof(trigger)); if (internalAction == null) throw new ArgumentNullException(nameof(internalAction)); - _representation.AddTriggerBehaviour(new InternalTriggerBehaviour.Async(trigger.Trigger, guard, (t, args) => internalAction( + _representation.AddTriggerBehaviour(new InternalTriggerBehaviour.Async(trigger.Trigger, TransitionGuard.ToPackedGuard(guard), (t, args) => internalAction( ParameterConversion.Unpack(args, 0), ParameterConversion.Unpack(args, 1), t))); return this; @@ -102,29 +178,18 @@ public StateConfiguration InternalTransitionAsyncIf(TriggerWithPar /// Function that must return true in order for the trigger to be accepted. /// The asynchronous action performed by the internal transition /// - public StateConfiguration InternalTransitionAsyncIf(TriggerWithParameters trigger, Func guard, Func internalAction) + public StateConfiguration InternalTransitionAsyncIf(TriggerWithParameters trigger, Func guard, Func internalAction) { if (trigger == null) throw new ArgumentNullException(nameof(trigger)); if (internalAction == null) throw new ArgumentNullException(nameof(internalAction)); - _representation.AddTriggerBehaviour(new InternalTriggerBehaviour.Async(trigger.Trigger, guard, (t, args) => internalAction( + _representation.AddTriggerBehaviour(new InternalTriggerBehaviour.Async(trigger.Trigger, TransitionGuard.ToPackedGuard(guard), (t, args) => internalAction( ParameterConversion.Unpack(args, 0), ParameterConversion.Unpack(args, 1), ParameterConversion.Unpack(args, 2), t))); return this; } - - /// - /// Add an internal transition to the state machine. An internal action does not cause the Exit and Entry actions to be triggered, and does not change the state of the state machine - /// - /// - /// - /// - public StateConfiguration InternalTransitionAsync(TTrigger trigger, Func entryAction) - { - return InternalTransitionAsyncIf(trigger, () => true, entryAction); - } /// /// Add an internal transition to the state machine. An internal action does not cause the Exit and Entry actions to be triggered, and does not change the state of the state machine /// @@ -138,11 +203,10 @@ public StateConfiguration InternalTransitionAsync(TTrigger trigger, Func i /// /// Add an internal transition to the state machine. An internal action does not cause the Exit and Entry actions to be triggered, and does not change the state of the state machine /// - /// /// The accepted trigger /// The asynchronous action performed by the internal transition /// - public StateConfiguration InternalTransitionAsync(TTrigger trigger, Func internalAction) + public StateConfiguration InternalTransitionAsync(TTrigger trigger, Func internalAction) { return InternalTransitionAsyncIf(trigger, () => true, internalAction); } @@ -155,7 +219,7 @@ public StateConfiguration InternalTransitionAsync(TTrigger trigger, Func< /// public StateConfiguration InternalTransitionAsync(TriggerWithParameters trigger, Func internalAction) { - return InternalTransitionAsyncIf(trigger, () => true, internalAction); + return InternalTransitionAsyncIf(trigger, t => true, internalAction); } /// @@ -168,7 +232,7 @@ public StateConfiguration InternalTransitionAsync(TriggerWithParameters public StateConfiguration InternalTransitionAsync(TriggerWithParameters trigger, Func internalAction) { - return InternalTransitionAsyncIf(trigger, () => true, internalAction); + return InternalTransitionAsyncIf(trigger, (t1, t2) => true, internalAction); } /// @@ -182,7 +246,7 @@ public StateConfiguration InternalTransitionAsync(TriggerWithParam /// public StateConfiguration InternalTransitionAsync(TriggerWithParameters trigger, Func internalAction) { - return InternalTransitionAsyncIf(trigger, () => true, internalAction); + return InternalTransitionAsyncIf(trigger, (t1, t2, t3) => true, internalAction); } /// @@ -230,7 +294,6 @@ public StateConfiguration OnEntryAsync(Func entryAction, string entryActio (t, args) => entryAction(), Reflection.InvocationInfo.Create(entryAction, entryActionDescription, Reflection.InvocationInfo.Timing.Asynchronous)); return this; - } /// diff --git a/src/Stateless/StateConfiguration.cs b/src/Stateless/StateConfiguration.cs index 3ddd78d3..beec0168 100644 --- a/src/Stateless/StateConfiguration.cs +++ b/src/Stateless/StateConfiguration.cs @@ -1120,7 +1120,7 @@ public StateConfiguration OnExit(Action exitAction, string exitActio /// Substates inherit the allowed transitions of their superstate. /// When entering directly into a substate from outside of the superstate, /// entry actions for the superstate are executed. - /// Likewise when leaving from the substate to outside the supserstate, + /// Likewise when leaving from the substate to outside the superstate, /// exit actions for the superstate will execute. /// /// The superstate. @@ -1774,7 +1774,7 @@ StateConfiguration InternalPermitDynamicIf(TTrigger trigger, FuncA stateConfiguration object public StateConfiguration InitialTransition(TState targetState) { - if (_representation.HasInitialTransition) throw new InvalidOperationException($"This state has already been configured with an inital transition ({_representation.InitialTransitionTarget})."); + if (_representation.HasInitialTransition) throw new InvalidOperationException($"This state has already been configured with an initial transition ({_representation.InitialTransitionTarget})."); if (targetState.Equals(State)) throw new ArgumentException("Setting the current state as the target destination state is not allowed.", nameof(targetState)); _representation.SetInitialTransition(targetState); diff --git a/src/Stateless/StateConfigurationResources.Designer.cs b/src/Stateless/StateConfigurationResources.Designer.cs index 0d9894f8..2eb924f5 100644 --- a/src/Stateless/StateConfigurationResources.Designer.cs +++ b/src/Stateless/StateConfigurationResources.Designer.cs @@ -37,7 +37,7 @@ internal StateConfigurationResources() { internal static global::System.Resources.ResourceManager ResourceManager { get { if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Stateless.StateConfigurationResources", typeof(StateConfigurationResources).GetAssembly()); + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Stateless.StateConfigurationResources", typeof(StateConfigurationResources).Assembly); resourceMan = temp; } return resourceMan; diff --git a/src/Stateless/StateMachine.Async.cs b/src/Stateless/StateMachine.Async.cs index f46a4825..c4e61e98 100644 --- a/src/Stateless/StateMachine.Async.cs +++ b/src/Stateless/StateMachine.Async.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; namespace Stateless @@ -10,7 +11,7 @@ namespace Stateless public partial class StateMachine { /// - /// Activates current state in asynchronous fashion. Actions associated with activating the currrent state + /// Activates current state in asynchronous fashion. Actions associated with activating the current state /// will be invoked. The activation is idempotent and subsequent activation of the same current state /// will not lead to re-execution of activation callbacks. /// @@ -21,7 +22,7 @@ public Task ActivateAsync() } /// - /// Deactivates current state in asynchronous fashion. Actions associated with deactivating the currrent state + /// Deactivates current state in asynchronous fashion. Actions associated with deactivating the current state /// will be invoked. The deactivation is idempotent and subsequent deactivation of the same current state /// will not lead to re-execution of deactivation callbacks. /// @@ -45,6 +46,23 @@ public Task FireAsync(TTrigger trigger) return InternalFireAsync(trigger, new object[0]); } + /// + /// Transition from the current state via the specified trigger in async fashion. + /// The target state is determined by the configuration of the current state. + /// Actions associated with leaving the current state and entering the new one + /// will be invoked. + /// + /// The trigger to fire. + /// A variable-length parameters list containing arguments. + /// The current state does + /// not allow the trigger to be fired. + public Task FireAsync(TriggerWithParameters trigger, params object[] args) + { + if (trigger == null) throw new ArgumentNullException(nameof(trigger)); + + return InternalFireAsync(trigger.Trigger, args); + } + /// /// Transition from the current state via the specified trigger in async fashion. /// The target state is determined by the configuration of the current state. @@ -144,12 +162,12 @@ async Task InternalFireQueuedAsync(TTrigger trigger, params object[] args) { _firing = true; - await InternalFireOneAsync(trigger, args).ConfigureAwait(false); + await InternalFireOneAsync(trigger, args).ConfigureAwait(RetainSynchronizationContext); while (_eventQueue.Count != 0) { var queuedEvent = _eventQueue.Dequeue(); - await InternalFireOneAsync(queuedEvent.Trigger, queuedEvent.Args).ConfigureAwait(false); + await InternalFireOneAsync(queuedEvent.Trigger, queuedEvent.Args).ConfigureAwait(RetainSynchronizationContext); } } finally @@ -188,8 +206,8 @@ async Task InternalFireOneAsync(TTrigger trigger, params object[] args) await HandleReentryTriggerAsync(args, representativeState, transition); break; } - case DynamicTriggerBehaviour _ when (result.Handler.ResultsInTransitionFrom(source, args, out var destination)): - case TransitioningTriggerBehaviour _ when (result.Handler.ResultsInTransitionFrom(source, args, out destination)): + case DynamicTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): + case TransitioningTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out destination): { // Handle transition, and set new state var transition = new Transition(source, destination, trigger, args); @@ -205,7 +223,11 @@ async Task InternalFireOneAsync(TTrigger trigger, params object[] args) if (itb is InternalTriggerBehaviour.Async ita) await ita.ExecuteAsync(transition, args); else - await Task.Run(() => itb.Execute(transition, args)); + if (RetainSynchronizationContext) + await Task.Factory.StartNew(() => itb.Execute(transition, args), + CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.FromCurrentSynchronizationContext()); + else + await Task.Run(() => itb.Execute(transition, args)); break; } default: @@ -225,15 +247,15 @@ private async Task HandleReentryTriggerAsync(object[] args, StateRepresentation transition = new Transition(transition.Destination, transition.Destination, transition.Trigger, args); await newRepresentation.ExitAsync(transition); - await _onTransitionedEvent.InvokeAsync(transition); + await _onTransitionedEvent.InvokeAsync(transition, RetainSynchronizationContext); representation = await EnterStateAsync(newRepresentation, transition, args); - await _onTransitionCompletedEvent.InvokeAsync(transition); + await _onTransitionCompletedEvent.InvokeAsync(transition, RetainSynchronizationContext); } else { - await _onTransitionedEvent.InvokeAsync(transition); + await _onTransitionedEvent.InvokeAsync(transition, RetainSynchronizationContext); representation = await EnterStateAsync(newRepresentation, transition, args); - await _onTransitionCompletedEvent.InvokeAsync(transition); + await _onTransitionCompletedEvent.InvokeAsync(transition, RetainSynchronizationContext); } State = representation.UnderlyingState; } @@ -246,17 +268,17 @@ private async Task HandleTransitioningTriggerAsync(object[] args, StateRepresent var newRepresentation = GetRepresentation(transition.Destination); //Alert all listeners of state transition - await _onTransitionedEvent.InvokeAsync(transition); + await _onTransitionedEvent.InvokeAsync(transition, RetainSynchronizationContext); var representation =await EnterStateAsync(newRepresentation, transition, args); - // Check if state has changed by entering new state (by fireing triggers in OnEntry or such) + // Check if state has changed by entering new state (by firing triggers in OnEntry or such) if (!representation.UnderlyingState.Equals(State)) { // The state has been changed after entering the state, must update current state to new one State = representation.UnderlyingState; } - await _onTransitionCompletedEvent.InvokeAsync(new Transition(transition.Source, State, transition.Trigger, transition.Parameters)); + await _onTransitionCompletedEvent.InvokeAsync(new Transition(transition.Source, State, transition.Trigger, transition.Parameters), RetainSynchronizationContext); } @@ -287,7 +309,7 @@ private async Task EnterStateAsync(StateRepresentation repr representation = GetRepresentation(representation.InitialTransitionTarget); // Alert all listeners of initial state transition - await _onTransitionedEvent.InvokeAsync(new Transition(transition.Destination, initialTransition.Destination, transition.Trigger, transition.Parameters)); + await _onTransitionedEvent.InvokeAsync(new Transition(transition.Destination, initialTransition.Destination, transition.Trigger, transition.Parameters), RetainSynchronizationContext); representation = await EnterStateAsync(representation, initialTransition, args); } diff --git a/src/Stateless/StateMachine.cs b/src/Stateless/StateMachine.cs index 2e374bb5..5d5a0e5f 100644 --- a/src/Stateless/StateMachine.cs +++ b/src/Stateless/StateMachine.cs @@ -64,7 +64,7 @@ public StateMachine(TState initialState) : this(initialState, FiringMode.Queued) /// /// A function that will be called to read the current state value. /// An action that will be called to write new state values. - /// Optional specification of fireing mode. + /// Optional specification of firing mode. public StateMachine(Func stateAccessor, Action stateMutator, FiringMode firingMode) : this() { _stateAccessor = stateAccessor ?? throw new ArgumentNullException(nameof(stateAccessor)); @@ -78,7 +78,7 @@ public StateMachine(Func stateAccessor, Action stateMutator, Fir /// Construct a state machine. /// /// The initial state. - /// Optional specification of fireing mode. + /// Optional specification of firing mode. public StateMachine(TState initialState, FiringMode firingMode) : this() { var reference = new StateReference { State = initialState }; @@ -89,6 +89,10 @@ public StateMachine(TState initialState, FiringMode firingMode) : this() _firingMode = firingMode; } + /// + /// For certain situations, it is essential that the SynchronizationContext is retained for all delegate calls. + /// + public bool RetainSynchronizationContext { get; set; } = false; /// /// Default constructor @@ -134,7 +138,6 @@ public IEnumerable GetPermittedTriggers(params object[] args) return CurrentRepresentation.GetPermittedTriggers(args); } -#if !NETSTANDARD1_0 /// /// Gets the currently-permissible triggers with any configured parameters. /// @@ -143,7 +146,6 @@ public IEnumerable> GetDetailedPermittedTrigger return CurrentRepresentation.GetPermittedTriggers(args) .Select(trigger => new TriggerDetails(trigger, _triggerConfiguration)); } -#endif StateRepresentation CurrentRepresentation { @@ -158,7 +160,7 @@ StateRepresentation CurrentRepresentation /// public StateMachineInfo GetInfo() { - var initialState = StateInfo.CreateStateInfo(new StateRepresentation(_initialState)); + var initialState = StateInfo.CreateStateInfo(new StateRepresentation(_initialState, RetainSynchronizationContext)); var representations = _stateConfiguration.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); @@ -168,7 +170,7 @@ public StateMachineInfo GetInfo() var reachable = behaviours .Distinct() .Except(representations.Keys) - .Select(underlying => new StateRepresentation(underlying)) + .Select(underlying => new StateRepresentation(underlying, RetainSynchronizationContext)) .ToArray(); foreach (var representation in reachable) @@ -186,7 +188,7 @@ StateRepresentation GetRepresentation(TState state) { if (!_stateConfiguration.TryGetValue(state, out StateRepresentation result)) { - result = new StateRepresentation(state); + result = new StateRepresentation(state, RetainSynchronizationContext); _stateConfiguration.Add(state, result); } @@ -418,9 +420,13 @@ void InternalFireOne(TTrigger trigger, params object[] args) HandleReentryTrigger(args, representativeState, transition); break; } - case DynamicTriggerBehaviour _ when (result.Handler.ResultsInTransitionFrom(source, args, out var destination)): - case TransitioningTriggerBehaviour _ when (result.Handler.ResultsInTransitionFrom(source, args, out destination)): + case DynamicTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out var destination): + case TransitioningTriggerBehaviour _ when result.Handler.ResultsInTransitionFrom(source, args, out destination): { + //If a trigger was found on a superstate that would cause unintended reentry, don't trigger. + if (source.Equals(destination)) + break; + // Handle transition, and set new state var transition = new Transition(source, destination, trigger, args); HandleTransitioningTrigger(args, representativeState, transition); @@ -476,7 +482,7 @@ private void HandleTransitioningTrigger( object[] args, StateRepresentation repr _onTransitionedEvent.Invoke(transition); var representation = EnterState(newRepresentation, transition, args); - // Check if state has changed by entering new state (by fireing triggers in OnEntry or such) + // Check if state has changed by entering new state (by firing triggers in OnEntry or such) if (!representation.UnderlyingState.Equals(State)) { // The state has been changed after entering the state, must update current state to new one diff --git a/src/Stateless/StateMachineResources.Designer.cs b/src/Stateless/StateMachineResources.Designer.cs index d80571de..4fb03195 100644 --- a/src/Stateless/StateMachineResources.Designer.cs +++ b/src/Stateless/StateMachineResources.Designer.cs @@ -38,7 +38,7 @@ internal StateMachineResources() { public static global::System.Resources.ResourceManager ResourceManager { get { if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Stateless.StateMachineResources", typeof(StateMachineResources).GetAssembly()); + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Stateless.StateMachineResources", typeof(StateMachineResources).Assembly); resourceMan = temp; } return resourceMan; diff --git a/src/Stateless/StateRepresentation.Async.cs b/src/Stateless/StateRepresentation.Async.cs index 8041e154..66e5e546 100644 --- a/src/Stateless/StateRepresentation.Async.cs +++ b/src/Stateless/StateRepresentation.Async.cs @@ -24,14 +24,10 @@ public void AddEntryAction(TTrigger trigger, Func ac if (action == null) throw new ArgumentNullException(nameof(action)); EntryActions.Add( - new EntryActionBehavior.Async((t, args) => - { - if (t.Trigger.Equals(trigger)) - return action(t, args); - - return TaskResult.Done; - }, - entryActionDescription)); + new EntryActionBehavior.AsyncFrom( + trigger, + action, + entryActionDescription)); } public void AddEntryAction(Func action, Reflection.InvocationInfo entryActionDescription) @@ -50,29 +46,29 @@ public void AddExitAction(Func action, Reflection.InvocationIn public async Task ActivateAsync() { if (_superstate != null) - await _superstate.ActivateAsync().ConfigureAwait(false); + await _superstate.ActivateAsync().ConfigureAwait(_retainSynchronizationContext); - await ExecuteActivationActionsAsync().ConfigureAwait(false); + await ExecuteActivationActionsAsync().ConfigureAwait(_retainSynchronizationContext); } public async Task DeactivateAsync() { - await ExecuteDeactivationActionsAsync().ConfigureAwait(false); + await ExecuteDeactivationActionsAsync().ConfigureAwait(_retainSynchronizationContext); if (_superstate != null) - await _superstate.DeactivateAsync().ConfigureAwait(false); + await _superstate.DeactivateAsync().ConfigureAwait(_retainSynchronizationContext); } async Task ExecuteActivationActionsAsync() { foreach (var action in ActivateActions) - await action.ExecuteAsync().ConfigureAwait(false); + await action.ExecuteAsync().ConfigureAwait(_retainSynchronizationContext); } async Task ExecuteDeactivationActionsAsync() { foreach (var action in DeactivateActions) - await action.ExecuteAsync().ConfigureAwait(false); + await action.ExecuteAsync().ConfigureAwait(_retainSynchronizationContext); } @@ -80,14 +76,14 @@ public async Task EnterAsync(Transition transition, params object[] entryArgs) { if (transition.IsReentry) { - await ExecuteEntryActionsAsync(transition, entryArgs).ConfigureAwait(false); + await ExecuteEntryActionsAsync(transition, entryArgs).ConfigureAwait(_retainSynchronizationContext); } else if (!Includes(transition.Source)) { if (_superstate != null && !(transition is InitialTransition)) - await _superstate.EnterAsync(transition, entryArgs).ConfigureAwait(false); + await _superstate.EnterAsync(transition, entryArgs).ConfigureAwait(_retainSynchronizationContext); - await ExecuteEntryActionsAsync(transition, entryArgs).ConfigureAwait(false); + await ExecuteEntryActionsAsync(transition, entryArgs).ConfigureAwait(_retainSynchronizationContext); } } @@ -95,11 +91,11 @@ public async Task ExitAsync(Transition transition) { if (transition.IsReentry) { - await ExecuteExitActionsAsync(transition).ConfigureAwait(false); + await ExecuteExitActionsAsync(transition).ConfigureAwait(_retainSynchronizationContext); } else if (!Includes(transition.Destination)) { - await ExecuteExitActionsAsync(transition).ConfigureAwait(false); + await ExecuteExitActionsAsync(transition).ConfigureAwait(_retainSynchronizationContext); // Must check if there is a superstate, and if we are leaving that superstate if (_superstate != null) @@ -110,13 +106,13 @@ public async Task ExitAsync(Transition transition) // Destination state is within the list, exit first superstate only if it is NOT the first if (!_superstate.UnderlyingState.Equals(transition.Destination)) { - return await _superstate.ExitAsync(transition).ConfigureAwait(false); + return await _superstate.ExitAsync(transition).ConfigureAwait(_retainSynchronizationContext); } } else { // Exit the superstate as well - return await _superstate.ExitAsync(transition).ConfigureAwait(false); + return await _superstate.ExitAsync(transition).ConfigureAwait(_retainSynchronizationContext); } } } @@ -126,13 +122,13 @@ public async Task ExitAsync(Transition transition) async Task ExecuteEntryActionsAsync(Transition transition, object[] entryArgs) { foreach (var action in EntryActions) - await action.ExecuteAsync(transition, entryArgs).ConfigureAwait(false); + await action.ExecuteAsync(transition, entryArgs).ConfigureAwait(_retainSynchronizationContext); } async Task ExecuteExitActionsAsync(Transition transition) { foreach (var action in ExitActions) - await action.ExecuteAsync(transition).ConfigureAwait(false); + await action.ExecuteAsync(transition).ConfigureAwait(_retainSynchronizationContext); } } } diff --git a/src/Stateless/StateRepresentation.cs b/src/Stateless/StateRepresentation.cs index c08f797f..6c306553 100644 --- a/src/Stateless/StateRepresentation.cs +++ b/src/Stateless/StateRepresentation.cs @@ -9,6 +9,7 @@ public partial class StateMachine internal partial class StateRepresentation { readonly TState _state; + private readonly bool _retainSynchronizationContext; internal IDictionary> TriggerBehaviours { get; } = new Dictionary>(); internal ICollection EntryActions { get; } = new List(); @@ -21,9 +22,10 @@ internal partial class StateRepresentation readonly ICollection _substates = new List(); public TState InitialTransitionTarget { get; private set; } = default; - public StateRepresentation(TState state) + public StateRepresentation(TState state, bool retainSynchronizationContext = false) { _state = state; + _retainSynchronizationContext = retainSynchronizationContext; } internal ICollection GetSubstates() @@ -47,8 +49,8 @@ public bool TryFindHandler(TTrigger trigger, object[] args, out TriggerBehaviour { TriggerBehaviourResult superStateHandler = null; - bool handlerFound = (TryFindLocalHandler(trigger, args, out TriggerBehaviourResult localHandler) || - (Superstate != null && Superstate.TryFindHandler(trigger, args, out superStateHandler))); + bool handlerFound = TryFindLocalHandler(trigger, args, out TriggerBehaviourResult localHandler) || + (Superstate != null && Superstate.TryFindHandler(trigger, args, out superStateHandler)); // If no handler for super state, replace by local handler (see issue #398) handler = superStateHandler ?? localHandler; @@ -230,7 +232,7 @@ internal void InternalAction(Transition transition, object[] args) { InternalTriggerBehaviour.Sync internalTransition = null; - // Look for actions in superstate(s) recursivly until we hit the topmost superstate, or we actually find some trigger handlers. + // Look for actions in superstate(s) recursively until we hit the topmost superstate, or we actually find some trigger handlers. StateRepresentation aStateRep = this; while (aStateRep != null) { diff --git a/src/Stateless/StateRepresentationResources.Designer.cs b/src/Stateless/StateRepresentationResources.Designer.cs index 9b262f2c..f3fdb295 100644 --- a/src/Stateless/StateRepresentationResources.Designer.cs +++ b/src/Stateless/StateRepresentationResources.Designer.cs @@ -37,7 +37,7 @@ internal StateRepresentationResources() { internal static global::System.Resources.ResourceManager ResourceManager { get { if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Stateless.StateRepresentationResources", typeof(StateRepresentationResources).GetAssembly()); + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Stateless.StateRepresentationResources", typeof(StateRepresentationResources).Assembly); resourceMan = temp; } return resourceMan; diff --git a/src/Stateless/Stateless.csproj b/src/Stateless/Stateless.csproj index da712780..0151d144 100644 --- a/src/Stateless/Stateless.csproj +++ b/src/Stateless/Stateless.csproj @@ -4,11 +4,11 @@ Stateless Stateless Stateless - netstandard2.0;netstandard1.0;net45;net40;net472;net5.0;net6.0 + netstandard2.0;net462;net6.0 Create state machines and lightweight state machine-based workflows directly in .NET code - Copyright © Stateless Contributors 2009-2019 + Copyright © Stateless Contributors 2009-$([System.DateTime]::Now.ToString(yyyy)) en-US - 5.13.0 + 5.14.0 Stateless Contributors true true @@ -21,15 +21,26 @@ false - - $(DefineConstants);PORTABLE_REFLECTION;TASKS + + true + true + true + snupkg - - + + $(DefineConstants);TASKS + + true + + + + + + diff --git a/test/Stateless.Tests/AsyncActionsFixture.cs b/test/Stateless.Tests/AsyncActionsFixture.cs index 4e9b63ba..79ff95f5 100644 --- a/test/Stateless.Tests/AsyncActionsFixture.cs +++ b/test/Stateless.Tests/AsyncActionsFixture.cs @@ -472,6 +472,99 @@ public void VerifyNotEnterSuperstateWhenDoingInitialTransition() Assert.Equal(State.D, sm.State); } + + [Fact] + public void OnEntryFromAsync_WhenTriggeredSynchronously_Throws() + { + var sm = new StateMachine(State.A); + + sm.Configure(State.A).Permit(Trigger.X, State.B); + + sm.Configure(State.B) + .OnEntryFromAsync(Trigger.X, async () => await Task.Run(() => { })); + + Assert.Throws(() => sm.Fire(Trigger.X)); + } + + [Fact] + public async Task OnEntryFromAsync_WhenTriggered_InvokesAction() + { + bool wasInvoked = false; + + var sm = new StateMachine(State.A); + + sm.Configure(State.A).Permit(Trigger.X, State.B); + + sm.Configure(State.B) + .OnEntryFromAsync(Trigger.X, async () => await Task.Run(() => { wasInvoked = true; })); + + await sm.FireAsync(Trigger.X); + + Assert.True(wasInvoked); + } + + [Fact] + public void OnEntryFromAsync_WhenEnteringByAnotherTriggerSynchronously_DoesNotThrow() + { + bool wasInvoked = false; + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .Permit(Trigger.X, State.B) + .Permit(Trigger.Y, State.B); + + sm.Configure(State.B) + .OnEntryFromAsync(Trigger.X, async () => await Task.Run(() => { wasInvoked = true; })); + + sm.Fire(Trigger.Y); + + Assert.False(wasInvoked); + } + + [Fact] + public async Task OnEntryFromAsync_WhenEnteringByAnotherTrigger_InvokesAction() + { + bool wasInvoked = false; + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .Permit(Trigger.X, State.B) + .Permit(Trigger.Y, State.B); + + sm.Configure(State.B) + .OnEntryFromAsync(Trigger.X, async () => await Task.Run(() => { wasInvoked = true; })); + + await sm.FireAsync(Trigger.Y); + + Assert.False(wasInvoked); + } + + [Fact] + public async Task FireAsync_TriggerWithMoreThanThreeParameters() + { + const string expectedParam = "42-Stateless-True-420.69-Y"; + string actualParam = null; + + var sm = new StateMachine(State.A); + + sm.Configure(State.A) + .Permit(Trigger.X, State.B); + + sm.Configure(State.B) + .OnEntryAsync(t => + { + actualParam = string.Join("-", t.Parameters); + return Task.CompletedTask; + }); + + var parameterizedX = sm.SetTriggerParameters(Trigger.X, typeof(int), typeof(string), typeof(bool), typeof(double), typeof(Trigger)); + + await sm.FireAsync(parameterizedX, 42, "Stateless", true, 420.69, Trigger.Y); + + Assert.Equal(expectedParam, actualParam); + } } } diff --git a/test/Stateless.Tests/AsyncFireingModesFixture.cs b/test/Stateless.Tests/AsyncFiringModesFixture.cs similarity index 93% rename from test/Stateless.Tests/AsyncFireingModesFixture.cs rename to test/Stateless.Tests/AsyncFiringModesFixture.cs index 5f7b6484..5802abe8 100644 --- a/test/Stateless.Tests/AsyncFireingModesFixture.cs +++ b/test/Stateless.Tests/AsyncFiringModesFixture.cs @@ -9,10 +9,10 @@ namespace Stateless.Tests /// /// This test class verifies that the firing modes are working as expected /// - public class AsyncFireingModesFixture + public class AsyncFiringModesFixture { /// - /// Check that the immediate fireing modes executes entry/exit out of order. + /// Check that the immediate Firing modes executes entry/exit out of order. /// [Fact] public void ImmediateEntryAProcessedBeforeEnterB() @@ -46,7 +46,7 @@ public void ImmediateEntryAProcessedBeforeEnterB() } /// - /// Checks that queued fireing mode executes triggers in order + /// Checks that queued Firing mode executes triggers in order /// [Fact] public void ImmediateEntryAProcessedBeforeEterB() @@ -79,10 +79,10 @@ public void ImmediateEntryAProcessedBeforeEterB() } /// - /// Check that the immediate fireing modes executes entry/exit out of order. + /// Check that the immediate Firing modes executes entry/exit out of order. /// [Fact] - public void ImmediateFireingOnEntryEndsUpInCorrectState() + public void ImmediateFiringOnEntryEndsUpInCorrectState() { var record = new List(); var sm = new StateMachine(State.A, FiringMode.Immediate); @@ -119,7 +119,7 @@ public void ImmediateFireingOnEntryEndsUpInCorrectState() } /// - /// Check that the immediate fireing modes executes entry/exit out of order. + /// Check that the immediate Firing modes executes entry/exit out of order. /// [Fact] public async Task ImmediateModeTransitionsAreInCorrectOrderWithAsyncDriving() diff --git a/test/Stateless.Tests/FireingModesFixture.cs b/test/Stateless.Tests/FiringModesFixture.cs similarity index 90% rename from test/Stateless.Tests/FireingModesFixture.cs rename to test/Stateless.Tests/FiringModesFixture.cs index 4afedd98..f3437274 100644 --- a/test/Stateless.Tests/FireingModesFixture.cs +++ b/test/Stateless.Tests/FiringModesFixture.cs @@ -7,10 +7,10 @@ namespace Stateless.Tests /// /// This test class verifies that the firing modes are working as expected /// - public class FireingModesFixture + public class FiringModesFixture { /// - /// Check that the immediate fireing modes executes entry/exit out of order. + /// Check that the immediate firing modes executes entry/exit out of order. /// [Fact] public void ImmediateEntryAProcessedBeforeEnterB() @@ -43,10 +43,10 @@ public void ImmediateEntryAProcessedBeforeEnterB() } /// - /// Checks that queued fireing mode executes triggers in order + /// Checks that queued firing mode executes triggers in order /// [Fact] - public void ImmediateEntryAProcessedBeforeEterB() + public void QueuedEntryAProcessedAfterEnterB() { var record = new List(); var sm = new StateMachine(State.A, FiringMode.Queued); @@ -76,10 +76,10 @@ public void ImmediateEntryAProcessedBeforeEterB() } /// - /// Check that the immediate fireing modes executes entry/exit out of order. + /// Check that the immediate firing modes executes entry/exit out of order. /// [Fact] - public void ImmediateFireingOnEntryEndsUpInCorrectState() + public void ImmediateFiringOnEntryEndsUpInCorrectState() { var record = new List(); var sm = new StateMachine(State.A, FiringMode.Immediate); diff --git a/test/Stateless.Tests/GetInfoFixture.cs b/test/Stateless.Tests/GetInfoFixture.cs new file mode 100644 index 00000000..d8a77825 --- /dev/null +++ b/test/Stateless.Tests/GetInfoFixture.cs @@ -0,0 +1,41 @@ +using System.Threading.Tasks; +using Xunit; + +namespace Stateless.Tests; + +public class GetInfoFixture +{ + [Fact] + public void GetInfo_should_return_Entry_action_with_trigger_name() + { + // ARRANGE + var sm = new StateMachine(State.A); + sm.Configure(State.B) + .OnEntryFrom(Trigger.X, () => { }); + + // ACT + var stateMachineInfo = sm.GetInfo(); + + // ASSERT + var stateInfo = Assert.Single(stateMachineInfo.States); + var entryActionInfo = Assert.Single(stateInfo.EntryActions); + Assert.Equal(Trigger.X.ToString(), entryActionInfo.FromTrigger); + } + + [Fact] + public void GetInfo_should_return_async_Entry_action_with_trigger_name() + { + // ARRANGE + var sm = new StateMachine(State.A); + sm.Configure(State.B) + .OnEntryFromAsync(Trigger.X, () => Task.CompletedTask); + + // ACT + var stateMachineInfo = sm.GetInfo(); + + // ASSERT + var stateInfo = Assert.Single(stateMachineInfo.States); + var entryActionInfo = Assert.Single(stateInfo.EntryActions); + Assert.Equal(Trigger.X.ToString(), entryActionInfo.FromTrigger); + } +} \ No newline at end of file diff --git a/test/Stateless.Tests/InternalTransitionAsyncFixture.cs b/test/Stateless.Tests/InternalTransitionAsyncFixture.cs index 0078a929..1cc9e68e 100644 --- a/test/Stateless.Tests/InternalTransitionAsyncFixture.cs +++ b/test/Stateless.Tests/InternalTransitionAsyncFixture.cs @@ -1,10 +1,243 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; using Xunit; namespace Stateless.Tests { public class InternalTransitionAsyncFixture { + [Fact] + public async Task InternalTransitionAsyncIf_AllowGuardWithParameter() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + const int intParam = 5; + var guardInvoked = false; + var callbackInvoked = false; + + sm.Configure(State.A) + .InternalTransitionAsyncIf(trigger, i => + { + guardInvoked = true; + Assert.Equal(intParam, i); + return true; + }, (i, transition) => + { + callbackInvoked = true; + Assert.Equal(intParam, i); + return Task.CompletedTask; + }); + + await sm.FireAsync(trigger, intParam); + + Assert.True(guardInvoked); + Assert.True(callbackInvoked); + } + + [Fact] + public async Task InternalTransitionAsyncIf_AllowGuardWithTwoParameters() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + const int intParam = 5; + const string stringParam = "5"; + var guardInvoked = false; + var callbackInvoked = false; + + sm.Configure(State.A) + .InternalTransitionAsyncIf(trigger, (i, s) => + { + guardInvoked = true; + Assert.Equal(intParam, i); + Assert.Equal(stringParam, s); + return true; + }, (i, s, transition) => + { + callbackInvoked = true; + Assert.Equal(intParam, i); + Assert.Equal(stringParam, s); + return Task.CompletedTask; + }); + + await sm.FireAsync(trigger, intParam, stringParam); + + Assert.True(guardInvoked); + Assert.True(callbackInvoked); + } + + [Fact] + public async Task InternalTransitionAsyncIf_AllowGuardWithThreeParameters() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + const int intParam = 5; + const string stringParam = "5"; + const bool boolParam = true; + var guardInvoked = false; + var callbackInvoked = false; + + sm.Configure(State.A) + .InternalTransitionAsyncIf(trigger, (i, s, b) => + { + guardInvoked = true; + Assert.Equal(intParam, i); + Assert.Equal(stringParam, s); + Assert.Equal(boolParam, b); + return true; + }, (i, s, b, transition) => + { + callbackInvoked = true; + Assert.Equal(intParam, i); + Assert.Equal(stringParam, s); + Assert.Equal(boolParam, b); + return Task.CompletedTask; + }); + + await sm.FireAsync(trigger, intParam, stringParam, boolParam); + + Assert.True(guardInvoked); + Assert.True(callbackInvoked); + } + + [Fact] + [Obsolete] + public async Task InternalTransitionAsyncIf_DeprecatedOverload_AllowGuardWithoutParameter() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + const int intParam = 5; + var guardInvoked = false; + var callbackInvoked = false; + + sm.Configure(State.A) + .InternalTransitionAsyncIf(trigger, () => + { + guardInvoked = true; + return true; + }, (i, transition) => + { + callbackInvoked = true; + Assert.Equal(intParam, i); + return Task.CompletedTask; + }); + + await sm.FireAsync(trigger, intParam); + + Assert.True(guardInvoked); + Assert.True(callbackInvoked); + } + + [Fact] + [Obsolete] + public async Task InternalTransitionAsyncIf_DeprecatedOverload_AllowGuardWithParameter() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + const int intParam = 5; + var guardInvoked = false; + var callbackInvoked = false; + + sm.Configure(State.A) + .InternalTransitionAsyncIf(trigger, () => + { + guardInvoked = true; + return true; + }, (i, transition) => + { + callbackInvoked = true; + Assert.Equal(intParam, i); + return Task.CompletedTask; + }); + + await sm.FireAsync(trigger, intParam); + + Assert.True(guardInvoked); + Assert.True(callbackInvoked); + } + + [Fact] + [Obsolete] + public async Task InternalTransitionAsyncIf_DeprecatedOverload_AllowGuardWithTwoParameters() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + const int intParam = 5; + const string stringParam = "5"; + var guardInvoked = false; + var callbackInvoked = false; + + sm.Configure(State.A) + .InternalTransitionAsyncIf(trigger, () => + { + guardInvoked = true; + return true; + }, (i, s, transition) => + { + callbackInvoked = true; + Assert.Equal(intParam, i); + Assert.Equal(stringParam, s); + return Task.CompletedTask; + }); + + await sm.FireAsync(trigger, intParam, stringParam); + + Assert.True(guardInvoked); + Assert.True(callbackInvoked); + } + + [Fact] + [Obsolete] + public async Task InternalTransitionAsyncIf_DeprecatedOverload_AllowGuardWithThreeParameters() + { + var sm = new StateMachine(State.A); + var trigger = sm.SetTriggerParameters(Trigger.X); + const int intParam = 5; + const string stringParam = "5"; + const bool boolParam = true; + var guardInvoked = false; + var callbackInvoked = false; + + sm.Configure(State.A) + .InternalTransitionAsyncIf(trigger, () => + { + guardInvoked = true; + return true; + }, (i, s, b, transition) => + { + callbackInvoked = true; + Assert.Equal(intParam, i); + Assert.Equal(stringParam, s); + Assert.Equal(boolParam, b); + return Task.CompletedTask; + }); + + await sm.FireAsync(trigger, intParam, stringParam, boolParam); + + Assert.True(guardInvoked); + Assert.True(callbackInvoked); + } + + [Fact] + [Obsolete] + public async Task InternalTransitionAsyncIf_DeprecatedOverload_GuardExecutedOnlyOnce() + { + var guardCalls = 0; + var order = new Order + { + Status = OrderStatus.OrderPlaced, + PaymentStatus = PaymentStatus.Pending, + }; + var stateMachine = new StateMachine(order.Status); + stateMachine.Configure(OrderStatus.OrderPlaced) + .InternalTransitionAsyncIf(OrderStateTrigger.PaymentCompleted, + () => PreCondition(ref guardCalls), + _ => ChangePaymentState(order, PaymentStatus.Completed)); + + await stateMachine.FireAsync(OrderStateTrigger.PaymentCompleted); + + Assert.Equal(1, guardCalls); + } + /// /// This unit test demonstrated bug report #417 /// @@ -48,4 +281,4 @@ private class Order public PaymentStatus PaymentStatus { get; internal set; } } } -} +} \ No newline at end of file diff --git a/test/Stateless.Tests/InternalTransitionFixture.cs b/test/Stateless.Tests/InternalTransitionFixture.cs index f54fb3a0..fac13f62 100644 --- a/test/Stateless.Tests/InternalTransitionFixture.cs +++ b/test/Stateless.Tests/InternalTransitionFixture.cs @@ -8,7 +8,7 @@ public class InternalTransitionFixture { /// - /// The expected behaviour of the internal transistion is that the state does not change. + /// The expected behaviour of the internal transition is that the state does not change. /// This will fail if the state changes after the trigger has fired. /// [Fact] diff --git a/test/Stateless.Tests/ReflectionFixture.cs b/test/Stateless.Tests/ReflectionFixture.cs index 2726760b..aebe54e9 100644 --- a/test/Stateless.Tests/ReflectionFixture.cs +++ b/test/Stateless.Tests/ReflectionFixture.cs @@ -587,7 +587,7 @@ void VerifyMethodNames(IEnumerable methods, string prefix, strin InvocationInfo method = methods.First(); if (state == State.A) - Assert.Equal(prefix + body + ((timing == InvocationInfo.Timing.Asynchronous) ? "Async" : ""), method.Description); + Assert.Equal(prefix + body + (timing == InvocationInfo.Timing.Asynchronous ? "Async" : ""), method.Description); else if (state == State.B) Assert.Equal(UserDescription + "B-" + body, method.Description); else if (state == State.C) @@ -612,15 +612,15 @@ void VerifyMethodNameses(IEnumerable methods, string prefix, str { if (state == State.A) { - matches = (method.Description == (prefix + body - + ((timing == InvocationInfo.Timing.Asynchronous) ? "Async" : "" + suffix))); + matches = method.Description == prefix + body + + (timing == InvocationInfo.Timing.Asynchronous ? "Async" : "" + suffix); } else if (state == State.B) - matches = (UserDescription + "B-" + body + suffix == method.Description); + matches = UserDescription + "B-" + body + suffix == method.Description; else if (state == State.C) - matches = (InvocationInfo.DefaultFunctionDescription == method.Description); + matches = InvocationInfo.DefaultFunctionDescription == method.Description; else if (state == State.D) - matches = (UserDescription + "D-" + body + suffix == method.Description); + matches = UserDescription + "D-" + body + suffix == method.Description; // if (matches) { diff --git a/test/Stateless.Tests/StateInfoTests.cs b/test/Stateless.Tests/StateInfoTests.cs new file mode 100644 index 00000000..edd8f33e --- /dev/null +++ b/test/Stateless.Tests/StateInfoTests.cs @@ -0,0 +1,25 @@ +using Stateless.Reflection; +using Xunit; + +namespace Stateless.Tests; + +public class StateInfoTests +{ + /// + /// For StateInfo, Substates, FixedTransitions and DynamicTransitions are only initialised by a call to AddRelationships. + /// However, for StateMachineInfo.InitialState, this never happens. Therefore StateMachineInfo.InitialState.Transitions + /// throws a System.ArgumentNullException. + /// + [Fact] + public void StateInfo_transitions_should_default_to_empty() + { + // ARRANGE + var stateInfo = StateInfo.CreateStateInfo(new StateMachine.StateRepresentation(State.A)); + + // ACT + var stateInfoTransitions = stateInfo.Transitions; + + // ASSERT + Assert.Null(stateInfoTransitions); + } +} \ No newline at end of file diff --git a/test/Stateless.Tests/StateMachineFixture.cs b/test/Stateless.Tests/StateMachineFixture.cs index 5f2803b9..f89b8560 100644 --- a/test/Stateless.Tests/StateMachineFixture.cs +++ b/test/Stateless.Tests/StateMachineFixture.cs @@ -108,6 +108,29 @@ public void WhenInSubstate_TriggerIgnoredInSuperstate_RemainsInSubstate() Assert.Equal(State.B, sm.State); } + [Fact] + public void WhenInSubstate_TriggerSuperStateTwiceToSameSubstate_DoesNotReenterSubstate() + { + var sm = new StateMachine(State.A); + var eCount = 0; + + sm.Configure(State.B) + .OnEntry(() => { eCount++;}) + .SubstateOf(State.C); + + sm.Configure(State.A) + .SubstateOf(State.C); + + + sm.Configure(State.C) + .Permit(Trigger.X, State.B); + + sm.Fire(Trigger.X); + sm.Fire(Trigger.X); + + Assert.Equal(1, eCount); + } + [Fact] public void PermittedTriggersIncludeSuperstatePermittedTriggers() { diff --git a/test/Stateless.Tests/SynchronizationContextFixture.cs b/test/Stateless.Tests/SynchronizationContextFixture.cs new file mode 100644 index 00000000..26eaccf6 --- /dev/null +++ b/test/Stateless.Tests/SynchronizationContextFixture.cs @@ -0,0 +1,342 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using Xunit.Sdk; + +namespace Stateless.Tests; + +public class SynchronizationContextFixture +{ + // Define a custom SynchronizationContext. All calls made to delegates should be with this context. + private readonly MaxConcurrencySyncContext _customSynchronizationContext = new(3); + private readonly List _capturedSyncContext = new(); + + private StateMachine GetSut(State initialState = State.A) + { + return new StateMachine(initialState, FiringMode.Queued) + { + RetainSynchronizationContext = true + }; + } + + private void SetSyncContext() + { + SynchronizationContext.SetSynchronizationContext(_customSynchronizationContext); + } + + /// + /// Simulate a call that loses the synchronization context + /// + private async Task CaptureThenLoseSyncContext() + { + CaptureSyncContext(); + await LoseSyncContext().ConfigureAwait(false); // ConfigureAwait false here to ensure we continue using the sync context returned by LoseSyncContext + } + + private void CaptureSyncContext() + { + _capturedSyncContext.Add(SynchronizationContext.Current); + } + + private async Task LoseSyncContext() + { + await Task.Run(() => { }).ConfigureAwait(false); // Switch synchronization context and continue + Assert.NotEqual(_customSynchronizationContext, SynchronizationContext.Current); + } + + /// + /// Tests capture the SynchronizationContext at various points through out their execution. + /// This asserts that every capture is the expected SynchronizationContext instance and that is hasn't been lost. + /// + /// Ensure that we have the expected number of captures + private void AssertSyncContextAlwaysRetained(int numberOfExpectedCalls) + { + Assert.Equal(numberOfExpectedCalls, _capturedSyncContext.Count); + Assert.All(_capturedSyncContext, actual => Assert.Equal(_customSynchronizationContext, actual)); + } + + /// + /// XUnit uses its own SynchronizationContext to execute each test. Therefore, placing SetSyncContext() in the constructor instead of + /// at the start of every test does not work as desired. This test ensures XUnit's behaviour has not changed. + /// + [Fact] + public void Ensure_XUnit_is_using_SyncContext() + { + SetSyncContext(); + CaptureSyncContext(); + AssertSyncContextAlwaysRetained(1); + } + + /// + /// SynchronizationContext are funny things. The way that they are lost varies depending on their implementation. + /// This test ensures that our mechanism for losing the SynchronizationContext works. + /// + [Fact] + public async Task Ensure_XUnit_can_lose_sync_context() + { + SetSyncContext(); + await LoseSyncContext().ConfigureAwait(false); + Assert.NotEqual(_customSynchronizationContext, SynchronizationContext.Current); + } + + [Fact] + public async Task Activation_of_state_with_superstate_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .OnActivateAsync(CaptureThenLoseSyncContext) + .SubstateOf(State.B); + ; + sm.Configure(State.B) + .OnActivateAsync(CaptureThenLoseSyncContext); + + // ACT + await sm.ActivateAsync(); + + // ASSERT + AssertSyncContextAlwaysRetained(2); + } + + [Fact] + public async Task Deactivation_of_state_with_superstate_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .OnDeactivateAsync(CaptureThenLoseSyncContext) + .SubstateOf(State.B); + ; + sm.Configure(State.B) + .OnDeactivateAsync(CaptureThenLoseSyncContext); + + // ACT + await sm.DeactivateAsync(); + + // ASSERT + AssertSyncContextAlwaysRetained(2); + } + + [Fact] + public async Task Multiple_activations_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .OnActivateAsync(CaptureThenLoseSyncContext) + .OnActivateAsync(CaptureThenLoseSyncContext) + .OnActivateAsync(CaptureThenLoseSyncContext); + + // ACT + await sm.ActivateAsync(); + + // ASSERT + AssertSyncContextAlwaysRetained(3); + } + + [Fact] + public async Task Multiple_Deactivations_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .OnDeactivateAsync(CaptureThenLoseSyncContext) + .OnDeactivateAsync(CaptureThenLoseSyncContext) + .OnDeactivateAsync(CaptureThenLoseSyncContext); + + // ACT + await sm.DeactivateAsync(); + + // ASSERT + AssertSyncContextAlwaysRetained(3); + } + + [Fact] + public async Task Multiple_OnEntry_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A).Permit(Trigger.X, State.B); + sm.Configure(State.B) + .OnEntryAsync(CaptureThenLoseSyncContext) + .OnEntryAsync(CaptureThenLoseSyncContext) + .OnEntryAsync(CaptureThenLoseSyncContext); + + // ACT + await sm.FireAsync(Trigger.X); + + // ASSERT + AssertSyncContextAlwaysRetained(3); + } + + [Fact] + public async Task Multiple_OnExit_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .Permit(Trigger.X, State.B) + .OnExitAsync(CaptureThenLoseSyncContext) + .OnExitAsync(CaptureThenLoseSyncContext) + .OnExitAsync(CaptureThenLoseSyncContext); + sm.Configure(State.B); + + // ACT + await sm.FireAsync(Trigger.X); + + // ASSERT + AssertSyncContextAlwaysRetained(3); + } + + [Fact] + public async Task OnExit_state_with_superstate_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(State.B); + sm.Configure(State.A) + .OnExitAsync(CaptureThenLoseSyncContext) + ; + + sm.Configure(State.B) + .SubstateOf(State.A) + .Permit(Trigger.X, State.C) + .OnExitAsync(CaptureThenLoseSyncContext) + ; + sm.Configure(State.C); + + // ACT + await sm.FireAsync(Trigger.X); + + // ASSERT + AssertSyncContextAlwaysRetained(2); + } + + [Fact] + public async Task OnExit_state_and_superstate_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(State.C); + sm.Configure(State.A); + + sm.Configure(State.B) + .SubstateOf(State.A) + .OnExitAsync(CaptureThenLoseSyncContext); + + sm.Configure(State.C) + .SubstateOf(State.B) + .Permit(Trigger.X, State.A) + .OnExitAsync(CaptureThenLoseSyncContext); + + // ACT + await sm.FireAsync(Trigger.X); + + // ASSERT + AssertSyncContextAlwaysRetained(2); + } + + [Fact] + public async Task Multiple_OnEntry_on_Reentry_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A).PermitReentry(Trigger.X) + .OnEntryAsync(CaptureThenLoseSyncContext) + .OnEntryAsync(CaptureThenLoseSyncContext); + + // ACT + await sm.FireAsync(Trigger.X); + + // ASSERT + AssertSyncContextAlwaysRetained(2); + } + + [Fact] + public async Task Multiple_OnExit_on_Reentry_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A).PermitReentry(Trigger.X) + .OnExitAsync(CaptureThenLoseSyncContext) + .OnExitAsync(CaptureThenLoseSyncContext); + + // ACT + await sm.FireAsync(Trigger.X); + + // ASSERT + AssertSyncContextAlwaysRetained(2); + } + + [Fact] + public async Task Trigger_firing_another_Trigger_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .InternalTransitionAsync(Trigger.X, async () => + { + await CaptureThenLoseSyncContext(); + await sm.FireAsync(Trigger.Y); + }) + .Permit(Trigger.Y, State.B) + ; + sm.Configure(State.B) + .OnEntryAsync(CaptureThenLoseSyncContext); + + // ACT + await sm.FireAsync(Trigger.X); + + // ASSERT + AssertSyncContextAlwaysRetained(2); + } + + [Fact] + public async Task OnTransition_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .Permit(Trigger.X, State.B); + + sm.Configure(State.B); + + sm.OnTransitionedAsync(_ => CaptureThenLoseSyncContext()); + sm.OnTransitionedAsync(_ => CaptureThenLoseSyncContext()); + sm.OnTransitionedAsync(_ => CaptureThenLoseSyncContext()); + + // ACT + await sm.FireAsync(Trigger.X); + + // ASSERT + AssertSyncContextAlwaysRetained(3); + } + + [Fact] + public async Task InternalTransition_firing_a_sync_action_should_retain_SyncContext() + { + // ARRANGE + SetSyncContext(); + var sm = GetSut(); + sm.Configure(State.A) + .InternalTransition(Trigger.X, CaptureSyncContext); + + // ACT + await sm.FireAsync(Trigger.X); + + // ASSERT + AssertSyncContextAlwaysRetained(1); + } +} \ No newline at end of file diff --git a/test/Stateless.Tests/TransitioningTriggerBehaviourFixture.cs b/test/Stateless.Tests/TransitioningTriggerBehaviourFixture.cs index 6d6ecb5e..6c739560 100644 --- a/test/Stateless.Tests/TransitioningTriggerBehaviourFixture.cs +++ b/test/Stateless.Tests/TransitioningTriggerBehaviourFixture.cs @@ -7,8 +7,8 @@ public class TransitioningTriggerBehaviourFixture [Fact] public void TransitionsToDestinationState() { - var transtioning = new StateMachine.TransitioningTriggerBehaviour(Trigger.X, State.C, null); - Assert.True(transtioning.ResultsInTransitionFrom(State.B, new object[0], out State destination)); + var transitioning = new StateMachine.TransitioningTriggerBehaviour(Trigger.X, State.C, null); + Assert.True(transitioning.ResultsInTransitionFrom(State.B, new object[0], out State destination)); Assert.Equal(State.C, destination); } }