From 66a6af45eecbd24c568489aa0bdcd710b58301c8 Mon Sep 17 00:00:00 2001 From: Yannick Voyer Date: Fri, 12 Dec 2025 23:30:10 -0500 Subject: [PATCH 1/2] Add support for 8.x --- composer.json | 2 +- tests/Callbacks/CallClosureOnFailureTest.php | 4 ++-- tests/StateMachineTest.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index e157113..fda8381 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ } }, "require": { - "php": ">=7.4", + "php": ">=7.4|^8.0", "webmozart/assert": "^1.0", "symfony/event-dispatcher": "^4.0|^5.0|^6.0|^7.0" }, diff --git a/tests/Callbacks/CallClosureOnFailureTest.php b/tests/Callbacks/CallClosureOnFailureTest.php index b9b92e6..5a21c7a 100644 --- a/tests/Callbacks/CallClosureOnFailureTest.php +++ b/tests/Callbacks/CallClosureOnFailureTest.php @@ -25,8 +25,8 @@ function () { 'context', new StateMachine( 'state', - $this->createMock(StateRegistry::class), - $this->createMock(EventRegistry::class), + $this->createStub(StateRegistry::class), + $this->createStub(EventRegistry::class), ) ); } diff --git a/tests/StateMachineTest.php b/tests/StateMachineTest.php index 9b4b021..07d134e 100644 --- a/tests/StateMachineTest.php +++ b/tests/StateMachineTest.php @@ -104,7 +104,7 @@ public function test_it_should_visit_the_transitions(): void { $registry = $this->createMock(StateRegistry::class); $machine = new StateMachine('', $registry, $this->events); - $visitor = $this->createMock(TransitionVisitor::class); + $visitor = $this->createStub(TransitionVisitor::class); $registry ->expects($this->once()) From f82d6c4466cda11fb74bef47b4c75c6b0d3bbcc3 Mon Sep 17 00:00:00 2001 From: Yannick Voyer Date: Sun, 14 Dec 2025 13:25:56 -0500 Subject: [PATCH 2/2] Fix #26 - Add information about transition in events (#37) * Introduce StateContext * Migrate all code to adapters for deprecation * Add adapter for userland * Add release branches builds * Make event support 7.1 --- .github/workflows/php.yml | 4 +- RELEASE_NOTES.md | 16 +++ examples/CallbackStateTest.php | 12 +- examples/ContextUsingBuilderTest.php | 24 ++-- examples/ContextUsingCustomMetadataTest.php | 13 ++- examples/DoctrineMappedContextTest.php | 8 +- src/Builder/StateBuilder.php | 26 +++-- src/Callbacks/AlwaysReturnStateOnFailure.php | 28 +++-- .../AlwaysThrowExceptionOnFailure.php | 19 +++- src/Callbacks/BufferStateChanges.php | 68 +++++++++++ src/Callbacks/CallClosureOnFailure.php | 14 ++- src/Callbacks/CallContextMethodOnFailure.php | 19 +++- src/Callbacks/NullCallback.php | 19 +++- src/Callbacks/TransitionCallback.php | 29 ++++- src/Context/ObjectAdapterContext.php | 52 +++++++++ src/Context/StringAdapterContext.php | 41 +++++++ src/Event/TransitionWasFailed.php | 34 +++++- src/Event/TransitionWasRequested.php | 30 ++++- src/Event/TransitionWasSuccessful.php | 30 ++++- src/InvalidStateTransitionException.php | 21 +++- src/StateContext.php | 17 +++ src/StateMachine.php | 43 +++++-- tests/Callbacks/BufferStateChangesTest.php | 84 ++++++++++++++ tests/Context/TestStubContext.php | 24 ++++ tests/StateBuilderTest.php | 4 +- tests/StateMachineTest.php | 106 +++++++++++++++++- tests/StateMetadataTest.php | 5 +- tests/TestContext.php | 10 +- 28 files changed, 699 insertions(+), 101 deletions(-) create mode 100644 src/Callbacks/BufferStateChanges.php create mode 100644 src/Context/ObjectAdapterContext.php create mode 100644 src/Context/StringAdapterContext.php create mode 100644 src/StateContext.php create mode 100644 tests/Callbacks/BufferStateChangesTest.php create mode 100644 tests/Context/TestStubContext.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index f363898..d1399e6 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -2,9 +2,9 @@ name: Php state machine on: push: - branches: [ master ] + branches: [ master, release/* ] pull_request: - branches: [ master ] + branches: [ master, release/* ] jobs: phpunit: diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 51ee2db..65426d0 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,21 @@ # Release notes +# 3.3.0 + +This release is a deprecation release. Deprecation warning will be issued when using a changed feature. + +* Introduction of a `StateContext` to replace the context that could be mixed. + +Before 3.3.0, a context could be `"post"` or an object that was converted to a FQCN. + +Starting with 4.0, we'll only accept implementation of `StateContext`. You will need your context to + implement the interface, or use the adapters [StringAdapterContext](src/Context/StringAdapterContext.php) or + [ObjectAdapterContext](src/Context/ObjectAdapterContext.php) if you don't want to add new custom code. + +# 3.2.0 + +[3.2.0](https://github.com/yvoyer/php-state/releases/tag/3.2.0) + # 3.1.0 * [#30](https://github.com/yvoyer/php-state/pull/30) diff --git a/examples/CallbackStateTest.php b/examples/CallbackStateTest.php index a5aad3d..e6b3a60 100644 --- a/examples/CallbackStateTest.php +++ b/examples/CallbackStateTest.php @@ -9,6 +9,7 @@ use Star\Component\State\Callbacks\TransitionCallback; use Star\Component\State\InvalidStateTransitionException; use Star\Component\State\RegistryBuilder; +use Star\Component\State\StateContext; use Star\Component\State\StateMachine; use Star\Component\State\StateMetadata; use Star\Component\State\StateTransition; @@ -58,7 +59,7 @@ public function test_workflow(): void } } -final class TurnStill +final class TurnStill implements StateContext { /** * @var TurnStillState|StateMetadata @@ -75,6 +76,11 @@ public function __construct() $this->state = new TurnStillState('locked'); } + public function toStateContextIdentifier(): string + { + return 'turn-still'; + } + public function pay(int $coin): void { $this->state = $this->state->transit( @@ -94,7 +100,7 @@ public function pass(): void { $this->state = $this->state->transit( 'pass', - 'turn-still', + $this, new CallClosureOnFailure( function () { return $this->state->transit('alarm', $this)->getCurrent(); @@ -105,7 +111,7 @@ function () { public function reset(): void { - $this->state = $this->state->transit('reset', 'turn-still'); + $this->state = $this->state->transit('reset', $this); } public function isLocked(): bool diff --git a/examples/ContextUsingBuilderTest.php b/examples/ContextUsingBuilderTest.php index 848ebbc..ed26c25 100644 --- a/examples/ContextUsingBuilderTest.php +++ b/examples/ContextUsingBuilderTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase; use Star\Component\State\Builder\StateBuilder; use Star\Component\State\InvalidStateTransitionException; +use Star\Component\State\StateContext; use Star\Component\State\StateMachine; final class ContextUsingBuilderTest extends TestCase @@ -192,7 +193,7 @@ public function test_it_should_allow_to_define_attributes_on_state(): void /** * Example of usage when using self contained workflow creation. */ -final class Post +final class Post implements StateContext { const ALIAS = 'post'; @@ -207,16 +208,18 @@ final class Post const ATTRIBUTE_ACTIVE = 'active'; const ATTRIBUTE_CLOSED = 'closed'; - /** - * @var string - */ - private $state; + private string $state; private function __construct(string $state) { $this->state = $state; } + public function toStateContextIdentifier(): string + { + return self::ALIAS; + } + public function isDraft(): bool { return $this->workflow()->isInState(self::STATE_DRAFT); @@ -244,23 +247,20 @@ public function isClosed(): bool public function moveToDraft(): void { - $this->state = $this->workflow()->transit(self::TRANSITION_TO_DRAFT, 'post'); + $this->state = $this->workflow()->transit(self::TRANSITION_TO_DRAFT, $this); } public function publish(): void { - $this->state = $this->workflow()->transit(self::TRANSITION_PUBLISH, 'post'); + $this->state = $this->workflow()->transit(self::TRANSITION_PUBLISH, $this); } public function archive(): void { - $this->state = $this->workflow()->transit(self::TRANSITION_ARCHIVE, 'post'); + $this->state = $this->workflow()->transit(self::TRANSITION_ARCHIVE, $this); } - /** - * @return Post - */ - public static function drafted() + public static function drafted(): self { return new self(self::STATE_DRAFT); } diff --git a/examples/ContextUsingCustomMetadataTest.php b/examples/ContextUsingCustomMetadataTest.php index cd15ff1..bb2e254 100644 --- a/examples/ContextUsingCustomMetadataTest.php +++ b/examples/ContextUsingCustomMetadataTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use Star\Component\State\Builder\StateBuilder; use Star\Component\State\RegistryBuilder; +use Star\Component\State\StateContext; use Star\Component\State\StateMetadata; use Star\Component\State\StateTransition; @@ -215,18 +216,20 @@ public function getDestinationState(): string } } -final class ContextStub +final class ContextStub implements StateContext { - /** - * @var MyStateWorkflow|StateMetadata - */ - public $state; + public MyStateWorkflow $state; public function __construct() { $this->state = new MyStateWorkflow(); } + public function toStateContextIdentifier(): string + { + return 'ContextStub'; + } + public function publish(): void { $this->state = $this->state->transit('publish', $this); diff --git a/examples/DoctrineMappedContextTest.php b/examples/DoctrineMappedContextTest.php index 65c9e8a..1b808b4 100644 --- a/examples/DoctrineMappedContextTest.php +++ b/examples/DoctrineMappedContextTest.php @@ -15,6 +15,7 @@ use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\TestCase; use Star\Component\State\Builder\StateBuilder; +use Star\Component\State\StateContext; use Star\Component\State\StateMetadata; final class DoctrineMappedContextTest extends TestCase @@ -85,7 +86,7 @@ private function save(MyEntity $entity): MyEntity /** * @Entity() */ -class MyEntity +class MyEntity implements StateContext { /** * @var int @@ -106,6 +107,11 @@ public function __construct() $this->state = new MyState(); } + public function toStateContextIdentifier(): string + { + throw new \RuntimeException(__METHOD__ . ' is not implemented yet.'); + } + public function isLocked(): bool { return $this->state->isInState('locked'); diff --git a/src/Builder/StateBuilder.php b/src/Builder/StateBuilder.php index 3033e4a..fe31e09 100644 --- a/src/Builder/StateBuilder.php +++ b/src/Builder/StateBuilder.php @@ -18,10 +18,19 @@ final class StateBuilder private TransitionRegistry $registry; private EventRegistry $listeners; - public function __construct() - { - $this->registry = new TransitionRegistry(); - $this->listeners = new EventDispatcherAdapter(); + public function __construct( + ?TransitionRegistry $registry = null, + ?EventRegistry $listeners = null + ) { + if (!$registry) { + $registry = new TransitionRegistry(); + } + $this->registry = $registry; + + if (!$listeners) { + $listeners = new EventDispatcherAdapter(); + } + $this->listeners = $listeners; } /** @@ -73,8 +82,11 @@ public function create(string $currentState): StateMachine return new StateMachine($currentState, $this->registry, $this->listeners); } - public static function build(): StateBuilder - { - return new static(); + public static function build( + ?TransitionRegistry $registry = null, + ?EventRegistry $listeners = null + ): StateBuilder { + // todo deprecate explict class in favor of interface + return new self($registry, $listeners); } } diff --git a/src/Callbacks/AlwaysReturnStateOnFailure.php b/src/Callbacks/AlwaysReturnStateOnFailure.php index f2f530e..f3fba76 100644 --- a/src/Callbacks/AlwaysReturnStateOnFailure.php +++ b/src/Callbacks/AlwaysReturnStateOnFailure.php @@ -3,6 +3,7 @@ namespace Star\Component\State\Callbacks; use Star\Component\State\InvalidStateTransitionException; +use Star\Component\State\StateContext; use Star\Component\State\StateMachine; final class AlwaysReturnStateOnFailure implements TransitionCallback @@ -14,31 +15,34 @@ public function __construct(string $to) $this->to = $to; } - /** - * @param mixed $context - * @param StateMachine $machine - */ - public function beforeStateChange($context, StateMachine $machine): void - { + public function beforeStateChange( + /* StateContext in 4.0 */ $context, + StateMachine $machine + ): void { } /** - * @param mixed $context + * @param string|object|StateContext $context * @param StateMachine $machine */ - public function afterStateChange($context, StateMachine $machine): void - { + public function afterStateChange( + /* StateContext in 4.0 */ $context, + StateMachine $machine + ): void { } /** * @param InvalidStateTransitionException $exception - * @param mixed $context + * @param string|object|StateContext $context * @param StateMachine $machine * * @return string */ - public function onFailure(InvalidStateTransitionException $exception, $context, StateMachine $machine): string - { + public function onFailure( + InvalidStateTransitionException $exception, + /* StateContext in 4.0 */ $context, + StateMachine $machine + ): string { return $this->to; } } diff --git a/src/Callbacks/AlwaysThrowExceptionOnFailure.php b/src/Callbacks/AlwaysThrowExceptionOnFailure.php index 6ab9e4e..1df6acb 100644 --- a/src/Callbacks/AlwaysThrowExceptionOnFailure.php +++ b/src/Callbacks/AlwaysThrowExceptionOnFailure.php @@ -11,16 +11,20 @@ final class AlwaysThrowExceptionOnFailure implements TransitionCallback * @param mixed $context * @param StateMachine $machine */ - public function beforeStateChange($context, StateMachine $machine): void - { + public function beforeStateChange( + /* StateContext in 4.0 */ $context, + StateMachine $machine + ): void { } /** * @param mixed $context * @param StateMachine $machine */ - public function afterStateChange($context, StateMachine $machine): void - { + public function afterStateChange( + /* StateContext in 4.0 */ $context, + StateMachine $machine + ): void { } /** @@ -31,8 +35,11 @@ public function afterStateChange($context, StateMachine $machine): void * @return string * @throws InvalidStateTransitionException */ - public function onFailure(InvalidStateTransitionException $exception, $context, StateMachine $machine): string - { + public function onFailure( + InvalidStateTransitionException $exception, + /* StateContext in 4.0 */ $context, + StateMachine $machine + ): string { throw $exception; } } diff --git a/src/Callbacks/BufferStateChanges.php b/src/Callbacks/BufferStateChanges.php new file mode 100644 index 0000000..dc0b9f7 --- /dev/null +++ b/src/Callbacks/BufferStateChanges.php @@ -0,0 +1,68 @@ +> + */ + private array $buffer = []; + + /** + * @param mixed|StateContext $context + * @return string + */ + private function extractContextIdentifier($context): string + { + if (! $context instanceof StateContext) { + if (is_object($context)) { + $context = get_class($context); + } + } else { + $context = $context->toStateContextIdentifier(); + } + Assert::string($context, 'Context is expected to be a string. Got: %s'); + + return $context; + } + + public function beforeStateChange( + /* StateContext in 4.0 */ $context, + StateMachine $machine + ): void { + $this->buffer[$this->extractContextIdentifier($context)][] = __FUNCTION__; + } + + public function afterStateChange( + /* StateContext in 4.0 */ $context, + StateMachine $machine + ): void { + $this->buffer[$this->extractContextIdentifier($context)][] = __FUNCTION__; + } + + public function onFailure( + InvalidStateTransitionException $exception, + /* StateContext in 4.0 */ $context, + StateMachine $machine + ): string { + $this->buffer[$this->extractContextIdentifier($context)][] = get_class($exception); + + return ''; + } + + /** + * @return array> + */ + public function flushBuffer(): array + { + return $this->buffer; + } +} diff --git a/src/Callbacks/CallClosureOnFailure.php b/src/Callbacks/CallClosureOnFailure.php index dad8d4d..f418500 100644 --- a/src/Callbacks/CallClosureOnFailure.php +++ b/src/Callbacks/CallClosureOnFailure.php @@ -22,16 +22,20 @@ public function __construct(Closure $callback) * @param mixed $context * @param StateMachine $machine */ - public function beforeStateChange($context, StateMachine $machine): void - { + public function beforeStateChange( + /* StateContext in 4.0 */ $context, + StateMachine $machine + ): void { } /** * @param mixed $context * @param StateMachine $machine */ - public function afterStateChange($context, StateMachine $machine): void - { + public function afterStateChange( + /* StateContext in 4.0 */ $context, + StateMachine $machine + ): void { } /** @@ -43,7 +47,7 @@ public function afterStateChange($context, StateMachine $machine): void */ public function onFailure( InvalidStateTransitionException $exception, - $context, + /* StateContext in 4.0 */ $context, StateMachine $machine ): string { $callback = $this->callback; diff --git a/src/Callbacks/CallContextMethodOnFailure.php b/src/Callbacks/CallContextMethodOnFailure.php index f244abc..f72b700 100644 --- a/src/Callbacks/CallContextMethodOnFailure.php +++ b/src/Callbacks/CallContextMethodOnFailure.php @@ -34,16 +34,20 @@ public function __construct( * @param mixed $context * @param StateMachine $machine */ - public function beforeStateChange($context, StateMachine $machine): void - { + public function beforeStateChange( + /* StateContext in 4.0 */ $context, + StateMachine $machine + ): void { } /** * @param mixed $context * @param StateMachine $machine */ - public function afterStateChange($context, StateMachine $machine): void - { + public function afterStateChange( + /* StateContext in 4.0 */ $context, + StateMachine $machine + ): void { } /** @@ -53,8 +57,11 @@ public function afterStateChange($context, StateMachine $machine): void * * @return string */ - public function onFailure(InvalidStateTransitionException $exception, $context, StateMachine $machine): string - { + public function onFailure( + InvalidStateTransitionException $exception, + /* StateContext in 4.0 */ $context, + StateMachine $machine + ): string { $closure = function (array $args) use ($context) { $context->{$this->method}(...$args); }; diff --git a/src/Callbacks/NullCallback.php b/src/Callbacks/NullCallback.php index 6b0b4db..8f960d6 100644 --- a/src/Callbacks/NullCallback.php +++ b/src/Callbacks/NullCallback.php @@ -11,16 +11,20 @@ final class NullCallback implements TransitionCallback * @param mixed $context * @param StateMachine $machine */ - public function beforeStateChange($context, StateMachine $machine): void - { + public function beforeStateChange( + /* StateContext in 4.0 */ $context, + StateMachine $machine + ): void { } /** * @param mixed $context * @param StateMachine $machine */ - public function afterStateChange($context, StateMachine $machine): void - { + public function afterStateChange( + /* StateContext in 4.0 */ $context, + StateMachine $machine + ): void { } /** @@ -30,8 +34,11 @@ public function afterStateChange($context, StateMachine $machine): void * * @return string */ - public function onFailure(InvalidStateTransitionException $exception, $context, StateMachine $machine): string - { + public function onFailure( + InvalidStateTransitionException $exception, + /* StateContext in 4.0 */ $context, + StateMachine $machine + ): string { throw new \RuntimeException('Method ' . __METHOD__ . ' should never be called.'); } } diff --git a/src/Callbacks/TransitionCallback.php b/src/Callbacks/TransitionCallback.php index de7107b..5d2d58d 100644 --- a/src/Callbacks/TransitionCallback.php +++ b/src/Callbacks/TransitionCallback.php @@ -3,28 +3,45 @@ namespace Star\Component\State\Callbacks; use Star\Component\State\InvalidStateTransitionException; +use Star\Component\State\StateContext; use Star\Component\State\StateMachine; interface TransitionCallback { /** - * @param mixed $context + * @param mixed|StateContext $context * @param StateMachine $machine + * @deprecated $context will expect a type of StateContext in 4.0, you need to update your implementations. + * @see StateContext */ - public function beforeStateChange($context, StateMachine $machine): void; + public function beforeStateChange( + /* StateContext in 4.0 */ $context, + StateMachine $machine, + ): void; /** - * @param mixed $context + * @param mixed|StateContext $context * @param StateMachine $machine + * @deprecated $context will expect a type of StateContext in 4.0, you need to update your implementations. + * @see StateContext */ - public function afterStateChange($context, StateMachine $machine): void; + public function afterStateChange( + /* StateContext in 4.0 */ $context, + StateMachine $machine, + ): void; /** * @param InvalidStateTransitionException $exception - * @param mixed $context + * @param mixed|StateContext $context * @param StateMachine $machine * * @return string The new state to move to on failure + * @deprecated $context will expect a type of StateContext in 4.0, you need to update your implementations. + * @see StateContext */ - public function onFailure(InvalidStateTransitionException $exception, $context, StateMachine $machine): string; + public function onFailure( + InvalidStateTransitionException $exception, + /* StateContext in 4.0 */ $context, + StateMachine $machine, + ): string; } diff --git a/src/Context/ObjectAdapterContext.php b/src/Context/ObjectAdapterContext.php new file mode 100644 index 0000000..d5819f5 --- /dev/null +++ b/src/Context/ObjectAdapterContext.php @@ -0,0 +1,52 @@ +object = $object; + + if ($triggerError) { + @trigger_error( + sprintf( + 'Passing an object of type "%s" that do not implement "%s" is deprecated. ' . + 'The object should implementing "%s" interface.', + get_class($this->object), + StateContext::class, + StateContext::class, + ), + E_USER_DEPRECATED, + ); + } + } + + public function toStateContextIdentifier(): string + { + $class = get_class($this->object); + $pos = strrpos($class, '\\'); + if ($pos > 0) { + $pos = $pos + 1; + } + + return substr($class, (int) $pos); + } +} diff --git a/src/Context/StringAdapterContext.php b/src/Context/StringAdapterContext.php new file mode 100644 index 0000000..592f03c --- /dev/null +++ b/src/Context/StringAdapterContext.php @@ -0,0 +1,41 @@ +context = $context; + if ($triggerError) { + @trigger_error( + sprintf( + 'Passing a string context "%s" is deprecated. ' . + 'You should provide your own class implementing "%s" interface.', + $this->context, + StateContext::class, + ), + E_USER_DEPRECATED, + ); + } + } + + public function toStateContextIdentifier(): string + { + return $this->context; + } +} diff --git a/src/Event/TransitionWasFailed.php b/src/Event/TransitionWasFailed.php index c74f13a..f6bd4e4 100644 --- a/src/Event/TransitionWasFailed.php +++ b/src/Event/TransitionWasFailed.php @@ -2,16 +2,29 @@ namespace Star\Component\State\Event; +use Star\Component\State\StateContext; use Symfony\Contracts\EventDispatcher\Event; +use Throwable; final class TransitionWasFailed extends Event implements StateEvent { private string $transition; - private \Throwable $exception; + private string $previousState; + private string $destinationState; + private StateContext $context; + private Throwable $exception; - public function __construct(string $transition, \Throwable $exception) - { + public function __construct( + string $transition, + string $previousState, + string $destinationState, + StateContext $context, + Throwable $exception + ) { $this->transition = $transition; + $this->previousState = $previousState; + $this->destinationState = $destinationState; + $this->context = $context; $this->exception = $exception; } @@ -20,6 +33,21 @@ public function transition(): string return $this->transition; } + public function getPreviousState(): string + { + return $this->previousState; + } + + public function getDestinationState(): string + { + return $this->destinationState; + } + + public function getContext(): StateContext + { + return $this->context; + } + public function exception(): \Throwable { return $this->exception; diff --git a/src/Event/TransitionWasRequested.php b/src/Event/TransitionWasRequested.php index 06c62f0..80643ff 100644 --- a/src/Event/TransitionWasRequested.php +++ b/src/Event/TransitionWasRequested.php @@ -7,19 +7,45 @@ namespace Star\Component\State\Event; +use Star\Component\State\StateContext; use Symfony\Contracts\EventDispatcher\Event; final class TransitionWasRequested extends Event implements StateEvent { private string $transition; + private string $previousState; + private string $destinationState; + private StateContext $context; - public function __construct(string $transition) - { + public function __construct( + string $transition, + string $previousState, + string $destinationState, + StateContext $context + ) { $this->transition = $transition; + $this->previousState = $previousState; + $this->destinationState = $destinationState; + $this->context = $context; } public function transition(): string { return $this->transition; } + + public function getPreviousState(): string + { + return $this->previousState; + } + + public function getDestinationState(): string + { + return $this->destinationState; + } + + public function getContext(): StateContext + { + return $this->context; + } } diff --git a/src/Event/TransitionWasSuccessful.php b/src/Event/TransitionWasSuccessful.php index 7ef6ef9..d652c3f 100644 --- a/src/Event/TransitionWasSuccessful.php +++ b/src/Event/TransitionWasSuccessful.php @@ -7,19 +7,45 @@ namespace Star\Component\State\Event; +use Star\Component\State\StateContext; use Symfony\Contracts\EventDispatcher\Event; final class TransitionWasSuccessful extends Event implements StateEvent { private string $transition; + private string $previousState; + private string $destinationState; + private StateContext $context; - public function __construct(string $transition) - { + public function __construct( + string $transition, + string $previousState, + string $destinationState, + StateContext $context + ) { $this->transition = $transition; + $this->previousState = $previousState; + $this->destinationState = $destinationState; + $this->context = $context; } public function transition(): string { return $this->transition; } + + public function getPreviousState(): string + { + return $this->previousState; + } + + public function getDestinationState(): string + { + return $this->destinationState; + } + + public function getContext(): StateContext + { + return $this->context; + } } diff --git a/src/InvalidStateTransitionException.php b/src/InvalidStateTransitionException.php index e7f6fd0..b707894 100644 --- a/src/InvalidStateTransitionException.php +++ b/src/InvalidStateTransitionException.php @@ -7,11 +7,16 @@ namespace Star\Component\State; +use Star\Component\State\Context\ObjectAdapterContext; +use Star\Component\State\Context\StringAdapterContext; +use function is_scalar; +use function sprintf; + final class InvalidStateTransitionException extends \Exception { /** * @param string $transition - * @param string|object $context + * @param string|object|StateContext $context * @param string $currentState * * @return static @@ -21,15 +26,19 @@ public static function notAllowedTransition( $context, string $currentState ): self { - if (\is_object($context)) { - $context = \get_class($context); + if (is_scalar($context)) { + $context = new StringAdapterContext((string) $context, true); + } + + if (!$context instanceof StateContext) { + $context = new ObjectAdapterContext($context, true); } - return new static( - \sprintf( + return new self( + sprintf( "The transition '%s' is not allowed when context '%s' is in state '%s'.", $transition, - $context, + $context->toStateContextIdentifier(), $currentState ) ); diff --git a/src/StateContext.php b/src/StateContext.php new file mode 100644 index 0000000..c51b19a --- /dev/null +++ b/src/StateContext.php @@ -0,0 +1,17 @@ +currentState; + $transition = $this->states->getTransition($transitionName); + $newState = $transition->getDestinationState(); $this->listeners->dispatch( StateEventStore::BEFORE_TRANSITION, - new TransitionWasRequested($transitionName) + new TransitionWasRequested( + $transitionName, + $previous, + $newState, + $context, + ) ); - $transition = $this->states->getTransition($transitionName); $callback->beforeStateChange($context, $this); - $newState = $transition->getDestinationState(); $allowed = $this->states->transitionStartsFrom($transitionName, $this->currentState); if (!$allowed) { $exception = InvalidStateTransitionException::notAllowedTransition( @@ -68,7 +84,13 @@ public function transit( $this->listeners->dispatch( StateEventStore::FAILURE_TRANSITION, - new TransitionWasFailed($transitionName, $exception) + new TransitionWasFailed( + $transitionName, + $previous, + $newState, + $context, + $exception, + ) ); $newState = $callback->onFailure($exception, $context, $this); @@ -80,7 +102,12 @@ public function transit( $this->listeners->dispatch( StateEventStore::AFTER_TRANSITION, - new TransitionWasSuccessful($transitionName) + new TransitionWasSuccessful( + $transitionName, + $previous, + $newState, + $context, + ) ); return $this->currentState; diff --git a/tests/Callbacks/BufferStateChangesTest.php b/tests/Callbacks/BufferStateChangesTest.php new file mode 100644 index 0000000..9b37f46 --- /dev/null +++ b/tests/Callbacks/BufferStateChangesTest.php @@ -0,0 +1,84 @@ +create(''); + + $buffer->beforeStateChange( + (object)[], + $machine + ); + $buffer->afterStateChange( + (object)[], + $machine + ); + + self::assertSame( + [ + 'stdClass' => [ + 0 => 'beforeStateChange', + 1 => 'afterStateChange', + ], + ], + $buffer->flushBuffer(), + ); + } + + public function test_it_should_buffer_context_as_string(): void + { + $buffer = new BufferStateChanges(); + $machine = StateBuilder::build() + ->create(''); + + $buffer->beforeStateChange( + 'stdClass', + $machine + ); + $buffer->afterStateChange( + 'stdClass', + $machine + ); + + self::assertSame( + [ + 'stdClass' => [ + 0 => 'beforeStateChange', + 1 => 'afterStateChange', + ], + ], + $buffer->flushBuffer(), + ); + } + + public function test_it_should_not_allow_non_string_context_in_before(): void + { + $buffer = new BufferStateChanges(); + $machine = StateBuilder::build() + ->create(''); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Context is expected to be a string. Got: integer'); + $buffer->beforeStateChange(42, $machine); + } + + public function test_it_should_not_allow_non_string_context_in_after(): void + { + $buffer = new BufferStateChanges(); + $machine = StateBuilder::build() + ->create(''); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Context is expected to be a string. Got: integer'); + $buffer->afterStateChange(42, $machine); + } +} diff --git a/tests/Context/TestStubContext.php b/tests/Context/TestStubContext.php new file mode 100644 index 0000000..34746ca --- /dev/null +++ b/tests/Context/TestStubContext.php @@ -0,0 +1,24 @@ +identifier = $identifier; + } + + public function toStateContextIdentifier(): string + { + return $this->identifier; + } +} diff --git a/tests/StateBuilderTest.php b/tests/StateBuilderTest.php index 5a9c854..433d64d 100644 --- a/tests/StateBuilderTest.php +++ b/tests/StateBuilderTest.php @@ -15,7 +15,7 @@ public function test_it_should_allow_to_transition_to_next_state_when_multiple_s ->create('from'); self::assertTrue($machine->isInState('from')); - $machine->transit('t1', 'context'); + $machine->transit('t1', new TestContext()); self::assertTrue($machine->isInState('to')); } @@ -29,7 +29,7 @@ public function test_it_should_return_whether_the_current_state_has_attribute_af self::assertTrue($machine->isInState('from')); self::assertTrue($machine->hasAttribute('attr')); - $machine->transit('t1', 'context'); + $machine->transit('t1', new TestContext()); self::assertTrue($machine->isInState('to')); self::assertFalse($machine->hasAttribute('attr')); diff --git a/tests/StateMachineTest.php b/tests/StateMachineTest.php index 07d134e..8be9577 100644 --- a/tests/StateMachineTest.php +++ b/tests/StateMachineTest.php @@ -8,10 +8,15 @@ namespace Star\Component\State; use PHPUnit\Framework\TestCase; +use Star\Component\State\Builder\StateBuilder; +use Star\Component\State\Callbacks\BufferStateChanges; +use Star\Component\State\Context\ObjectAdapterContext; +use Star\Component\State\Context\StringAdapterContext; +use Star\Component\State\Context\TestStubContext; use Star\Component\State\Event\StateEventStore; use Star\Component\State\Event\TransitionWasFailed; -use Star\Component\State\Event\TransitionWasSuccessful; use Star\Component\State\Event\TransitionWasRequested; +use Star\Component\State\Event\TransitionWasSuccessful; use Star\Component\State\Stub\EventRegistrySpy; use Star\Component\State\Transitions\OneToOneTransition; use stdClass; @@ -78,7 +83,7 @@ public function test_it_should_throw_exception_with_class_context_when_transitio $this->expectExceptionMessage( "The transition 't' is not allowed when context 'stdClass' is in state 'current'." ); - $this->machine->transit('t', new stdClass); + $this->machine->transit('t', new ObjectAdapterContext(new stdClass, false)); } public function test_it_should_throw_exception_with_context_as_string_when_transition_not_allowed(): void @@ -90,7 +95,7 @@ public function test_it_should_throw_exception_with_context_as_string_when_trans $this->expectExceptionMessage( "The transition 'transition' is not allowed when context 'c' is in state 'current'." ); - $this->machine->transit('transition', 'c'); + $this->machine->transit('transition', new StringAdapterContext('c')); } public function test_state_can_have_attribute(): void @@ -124,7 +129,7 @@ public function test_it_should_dispatch_an_event_before_a_transition_has_failed( { $this->registry->addTransition(new OneToOneTransition('t', 'from', 'to')); try { - $this->machine->transit('t', 'context'); + $this->machine->transit('t', new TestContext()); $this->fail('An exception should have been thrown'); } catch (Throwable $exception) { // silence it @@ -136,4 +141,97 @@ public function test_it_should_dispatch_an_event_before_a_transition_has_failed( self::assertCount(1, $events); self::assertContainsOnlyInstancesOf(TransitionWasFailed::class, $events); } + + public function test_it_should_invoke_before_state_change_callback(): void + { + $this->registry->addTransition(new OneToOneTransition('t', 'current', 'to')); + $buffer = new BufferStateChanges(); + + self::assertSame( + [], + $buffer->flushBuffer(), + ); + + $this->machine->transit( + 't', + new TestStubContext('context'), + $buffer, + ); + + self::assertSame( + [ + 'context' => [ + 'beforeStateChange', + 'afterStateChange', + ], + ], + $buffer->flushBuffer(), + ); + } + + public function test_it_should_allow_to_transit_using_state_context(): void + { + $machine = StateBuilder::build(null, $this->events) + ->allowTransition('activate', 'left', 'right') + ->create('left'); + $context = new TestStubContext('post'); + + $machine->transit( + 'activate', + $context, + $callback = new BufferStateChanges(), + ); + + self::assertSame( + [ + 'post' => [ + 'beforeStateChange', + 'afterStateChange', + ], + ], + $callback->flushBuffer() + ); + self::assertCount( + 1, + $this->events->getDispatchedEvents(StateEventStore::BEFORE_TRANSITION) + ); + self::assertCount( + 1, + $this->events->getDispatchedEvents(StateEventStore::AFTER_TRANSITION) + ); + } + + public function test_it_should_allow_handle_failure_with_state_context(): void + { + $machine = StateBuilder::build(null, $this->events) + ->allowTransition('activate', 'right', 'left') + ->create('left'); + $context = new TestStubContext('post'); + $callback = new BufferStateChanges(); + + $machine->transit( + 'activate', + $context, + $callback + ); + + self::assertSame( + [ + 'post' => [ + 'beforeStateChange', + InvalidStateTransitionException::class, + 'afterStateChange', + ], + ], + $callback->flushBuffer() + ); + self::assertCount( + 1, + $this->events->getDispatchedEvents(StateEventStore::BEFORE_TRANSITION) + ); + self::assertCount( + 1, + $this->events->getDispatchedEvents(StateEventStore::AFTER_TRANSITION) + ); + } } diff --git a/tests/StateMetadataTest.php b/tests/StateMetadataTest.php index af4df95..80a9515 100644 --- a/tests/StateMetadataTest.php +++ b/tests/StateMetadataTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use Star\Component\State\Builder\StateBuilder; use Star\Component\State\Callbacks\NullCallback; +use Star\Component\State\Context\TestStubContext; final class StateMetadataTest extends TestCase { @@ -24,7 +25,7 @@ public function test_it_should_check_if_has_attribute(): void public function test_it_should_transit(): void { $metadata = new CustomMetadata('from'); - $new = $metadata->transit('t1', 'context'); + $new = $metadata->transit('t1', new TestStubContext('context')); self::assertTrue($new->isInState('to')); } @@ -38,7 +39,7 @@ public function test_it_should_use_the_failure_callback_on_transit(): void ); $metadata->transit( 't1', - 'context', + new TestStubContext('context'), new NullCallback() ); } diff --git a/tests/TestContext.php b/tests/TestContext.php index 2543164..49b0408 100644 --- a/tests/TestContext.php +++ b/tests/TestContext.php @@ -7,6 +7,14 @@ namespace Star\Component\State; -final class TestContext +/** + * @deprecated Will be removed in 4.0. You should use StateContext. + * @see StateContext + */ +final class TestContext implements StateContext { + public function toStateContextIdentifier(): string + { + return 'context'; + } }