diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b509bd5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +/.gitattributes export-ignore +/.github export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore diff --git a/README.md b/README.md index 3ae14b9..af1a80f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# State Engine (PHP) +# State Engine / Machine (PHP) [![CI](https://github.com/uuf6429/state-engine-php/actions/workflows/ci.yml/badge.svg)](https://github.com/uuf6429/state-engine-php/actions/workflows/ci.yml) [![Minimum PHP Version](https://img.shields.io/badge/php-%5E7.4%20%7C%20%5E8-8892BF.svg)](https://php.net/) @@ -6,9 +6,14 @@ [![Latest Stable Version](http://poser.pugx.org/uuf6429/state-engine/v)](https://packagist.org/packages/uuf6429/state-engine) [![Latest Unstable Version](http://poser.pugx.org/uuf6429/state-engine/v/unstable)](https://packagist.org/packages/uuf6429/state-engine) -This library provides some interfaces and a basic implementation of a State Engine. +This library provides some interfaces and a basic implementation of a State Engine or State Machine. **Highlights:** +- Dual functionality: + 1. Either as a basic state engine; switching to a desired state as long the transition is defined) + ([see "JiraIssueTest"](#jiraissuetest-state-engine)) + 2. Or a more sophisticated state machine; same as above but matching data for any state + ([see "TurnstileTest"](#turnstiletest-state-machine)) - Highly composable - everything can be replaced as desired - [PSR-14](http://www.php-fig.org/psr/psr-14/) (Event Dispatcher) compatible - Fluent builder interface ([see "From Scratch"](#from-scratch)) @@ -19,7 +24,7 @@ This library provides some interfaces and a basic implementation of a State Engi The recommended and easiest way to install this library is through [Composer](https://getcomposer.org/): ```bash -composer require uuf6429/state-engine-php "^1.0" +composer require uuf6429/state-engine-php "^2.0" ``` ## Why? @@ -60,32 +65,26 @@ In this case, having the [`StateTraversion`](https://github.com/uuf6429/state-en Here's a quick & dirty example with the provided implementation (that assumes that there is a "door" model): ```php -use App\Models\Door; // example model +use App\Models\Door; // example model that implements StateAwareInterface use uuf6429\StateEngine\Implementation\Builder; use uuf6429\StateEngine\Implementation\Entities\State; $doorStateManager = Builder::create() - ->addState('open', 'Open') - ->addState('closed', 'Closed') - ->addState('locked', 'Locked') - ->addTransition('open', 'closed', 'Close the door') - ->addTransition('closed', 'locked', 'Lock the door') - ->addTransition('locked', 'closed', 'Unlock the door') - ->addTransition('closed', 'open', 'Open the door') + ->defState('open', 'Open') + ->defState('closed', 'Closed') + ->defState('locked', 'Locked') + ->defTransition('open', 'closed', 'Close the door') + ->defTransition('closed', 'locked', 'Lock the door') + ->defTransition('locked', 'closed', 'Unlock the door') + ->defTransition('closed', 'open', 'Open the door') ->getEngine(); // you can pass an event dispatcher to the engine here // find Door 123 (laravel-style repository-model) $door = Door::find(123); -// build a state mutator (useful when the model does not have get/setState) -$doorStateMutator = Builder::stateMutator( - static fn(): State => new State($door->status), // getter - static fn(State $newState) => $door->update(['status' => $newState->getName()]) // setter -); - // close the door :) -$doorStateManager->changeState($doorStateMutator, new State('closed')); +$doorStateManager->changeState($door, new State('closed')); ``` ### From Scratch (Custom) @@ -100,6 +99,7 @@ For example, you could store states or transitions in a database, in which case The library provides some flexibility so that you can connect your existing code with it. In more complicated scenarios, you may have to build a small layer to bridge the gap. The example below illustrates how one can handle models with flags instead of a single state. + ```php use App\Models\Door; // example model @@ -108,7 +108,7 @@ use uuf6429\StateEngine\Implementation\Entities\State; $door = Door::find(123); -$doorStateMutator = Builder::stateMutator( +$doorStateMutator = Builder::makeStateMutator( static function () use ($door): State { // getter if ($door->is_locked) { return new State('locked'); @@ -125,12 +125,45 @@ $doorStateMutator = Builder::stateMutator( ]); } ); + +// assumes engine $doorStateManager was already defined +$doorStateManager->changeState($doorStateMutator, new State('closed')); ``` ## Examples & Testing -The [`JiraIssueTest`](https://github.com/uuf6429/state-engine-php/blob/main/test/JiraIssueTest.php) class serves as a test as well as a realistic example of how Jira Issue states could be set up. +### [`JiraIssueTest`](https://github.com/uuf6429/state-engine-php/blob/main/tests/JiraIssueTest.php) State Engine + +This test provides a realistic example of how Jira Issue states could be set up. The test also generates the PlantUML diagram below (embedded as an image due to GFM limitations): -![example](https://www.planttext.com/api/plantuml/svg/TPBDRiCW48JlFCKUauDV88SgZgfAlLIrymGqJ2rK31PiBENjYurfux_hpZVB370EB3tVMoF4uI9lFyOrHogA5pgKLff7qE589xgWqPRaD5cIxvPUqG_ScmnSi8ygVJjF2ZsCwrfO5a_xHbCDgHuZDNcpJZVNTWQCbUNlr1FLuBktn8w-qb0i5wuwV02AMkSHOx7K9cnR_ikaqhCEMLmqgCg1lyAg8L5Lxe8r36J0nbNvfEmwfqnNTjqyqZn5hf0IfGQCmDes8i-tDrTbZAGDr1xtb3sodpA4WTtG9rzmfeTAZpKg8vsdwmTr7QmGvtY9yJV-0W00) +![jira issue example](https://www.planttext.com/api/plantuml/svg/TPBDRiCW48JlFCKUauDV88SgZgfAlLIrymGqJ2rK31PiBENjYurfux_hpZVB370EB3tVMoF4uI9lFyOrHogA5pgKLff7qE589xgWqPRaD5cIxvPUqG_ScmnSi8ygVJjF2ZsCwrfO5a_xHbCDgHuZDNcpJZVNTWQCbUNlr1FLuBktn8w-qb0i5wuwV02AMkSHOx7K9cnR_ikaqhCEMLmqgCg1lyAg8L5Lxe8r36J0nbNvfEmwfqnNTjqyqZn5hf0IfGQCmDes8i-tDrTbZAGDr1xtb3sodpA4WTtG9rzmfeTAZpKg8vsdwmTr7QmGvtY9yJV-0W00) + +### [`TurnstileTest`](https://github.com/uuf6429/state-engine-php/blob/main/tests/JiraIssueTest.php) State Machine + +This test illustrates how a [state machine](https://en.wikipedia.org/wiki/Finite-state_machine) can be used to model a [turnstile gate](https://en.wikipedia.org/wiki/Turnstile). +As before, here's the generated diagram: + +![turnstile example](https://www.planttext.com/api/plantuml/svg/SoWkIImgAStDuUBIyCmjI2mkJapAITLKqDMrKz08W7Ej59ppC_CK2d8IarDJk90amEgGDLef1AGM5UVdAPGdvcGNAvHa5EMNfcTmSJcavgM0h040) + +Here's how the state machine definition looks like and is used: +```php +use App\Models\Turnstile; // example model that implements StateAwareInterface + +use uuf6429\StateEngine\Implementation\Builder; + +$turnstileStateMachine = Builder::create() + // make states + ->defState('locked', 'Impassable') + ->defState('open', 'Passable') + // make transitions + ->defDataTransition('locked', ['insert_coin'], 'open', 'Coin placed') + ->defDataTransition('open', ['walk_through'], 'locked', 'Person walks through') + ->getMachine(); + +$turnstile = Turnstile::find(123); + +// put coin in turnstile (notice that the final state is not mentioned) +$turnstileStateMachine->processInput($turnstile, ['insert_coin']); +``` diff --git a/composer.json b/composer.json index cd40c52..2487aa0 100644 --- a/composer.json +++ b/composer.json @@ -1,32 +1,40 @@ { - "name": "uuf6429/state-engine", - "type": "library", - "homepage": "https://github.com/uuf6429/state-engine-php", - "readme": "README.md", - "license": "MIT", - "description": "A library providing interfaces and basic implementation of a State Engine", - "keywords": ["state", "engine", "state-engine", "uuf6429"], - "authors": [ - { - "name": "Christian Sciberras", - "email": "christian@sciberras.me" - } - ], - "require": { - "php": "^7.4 || ^8.0", - "psr/event-dispatcher": "^1.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.5" - }, - "autoload": { - "psr-4": { - "uuf6429\\StateEngine\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "uuf6429\\StateEngine\\": "test/" - } + "name": "uuf6429/state-engine", + "type": "library", + "homepage": "https://github.com/uuf6429/state-engine-php", + "readme": "README.md", + "license": "MIT", + "description": "A library providing interfaces and basic implementation of a State Engine or Machine", + "keywords": [ + "state", + "engine", + "state-engine", + "machine", + "state-machine", + "workflow", + "uuf6429" + ], + "authors": [ + { + "name": "Christian Sciberras", + "email": "christian@sciberras.me" } + ], + "require": { + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "autoload": { + "psr-4": { + "uuf6429\\StateEngine\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "uuf6429\\StateEngine\\": "tests/" + } + } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 85d81d5..8e8ab47 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -3,7 +3,7 @@ xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd"> - ./test + ./tests diff --git a/src/Exceptions/BuilderStateAlreadyDeclaredException.php b/src/Exceptions/BuilderStateAlreadyDeclaredException.php index afe4637..8e1428b 100644 --- a/src/Exceptions/BuilderStateAlreadyDeclaredException.php +++ b/src/Exceptions/BuilderStateAlreadyDeclaredException.php @@ -2,10 +2,12 @@ namespace uuf6429\StateEngine\Exceptions; +use uuf6429\StateEngine\Interfaces\StateInterface; + class BuilderStateAlreadyDeclaredException extends InvalidArgumentException { - public function __construct(string $name) + public function __construct(StateInterface $state) { - parent::__construct("Cannot add state \"$name\", it has already been declared."); + parent::__construct("Cannot add state \"$state\", it has already been declared."); } } diff --git a/src/Exceptions/BuilderStateNotDeclaredException.php b/src/Exceptions/BuilderStateNotDeclaredException.php index bbbe81e..112acc1 100644 --- a/src/Exceptions/BuilderStateNotDeclaredException.php +++ b/src/Exceptions/BuilderStateNotDeclaredException.php @@ -2,10 +2,12 @@ namespace uuf6429\StateEngine\Exceptions; +use uuf6429\StateEngine\Interfaces\StateInterface; + class BuilderStateNotDeclaredException extends InvalidArgumentException { - public function __construct(string $name) + public function __construct(StateInterface $state) { - parent::__construct("Cannot use state \"$name\", since it has not been declared yet."); + parent::__construct("Cannot use state \"$state\", since it has not been declared yet."); } } diff --git a/src/Exceptions/BuilderTransitionAlreadyDeclaredException.php b/src/Exceptions/BuilderTransitionAlreadyDeclaredException.php index 6ad3329..c24c9f5 100644 --- a/src/Exceptions/BuilderTransitionAlreadyDeclaredException.php +++ b/src/Exceptions/BuilderTransitionAlreadyDeclaredException.php @@ -2,10 +2,12 @@ namespace uuf6429\StateEngine\Exceptions; +use uuf6429\StateEngine\Interfaces\TransitionInterface; + class BuilderTransitionAlreadyDeclaredException extends InvalidArgumentException { - public function __construct(string $oldState, string $newState) + public function __construct(TransitionInterface $transition) { - parent::__construct("Cannot add transition from \"$oldState\" to \"$newState\", it has already been declared."); + parent::__construct("Cannot add transition \"$transition\", it has already been declared."); } } diff --git a/src/Exceptions/TransitionNotAllowedException.php b/src/Exceptions/TransitionNotAllowedException.php deleted file mode 100644 index 5afd3b2..0000000 --- a/src/Exceptions/TransitionNotAllowedException.php +++ /dev/null @@ -1,17 +0,0 @@ -getOldState()->getName(), - $transition->getNewState()->getName() - )); - } -} diff --git a/src/Exceptions/TransitionNotDeclaredException.php b/src/Exceptions/TransitionNotDeclaredException.php new file mode 100644 index 0000000..e1419bd --- /dev/null +++ b/src/Exceptions/TransitionNotDeclaredException.php @@ -0,0 +1,13 @@ +dispatcher = $dispatcher; } - public function changeState(StateAwareInterface $item, StateInterface $newState): void + public function execute(StateAwareInterface $item, TransitionInterface $transition): void { - $transition = new Entities\Transition($item->getState(), $newState); - $this->applyTransition($item, $transition); - } - - public function applyTransition(StateAwareInterface $item, TransitionInterface $transition): void - { - if (!$this->repository->has($transition)) { - throw new TransitionNotAllowedException($transition); + if (!($matched = $this->repository->find($transition))) { + throw new TransitionNotDeclaredException($transition); } - $this->dispatcher && $this->dispatcher->dispatch(new Events\StateChanging($item, $transition->getNewState())); + $this->dispatcher && $this->dispatcher->dispatch(new Events\StateChanging($item, $matched->getNewState())); - $item->setState($transition->getNewState()); + $item->setState($matched->getNewState()); - $this->dispatcher && $this->dispatcher->dispatch(new Events\StateChanged($item, $transition->getOldState())); + $this->dispatcher && $this->dispatcher->dispatch(new Events\StateChanged($item, $matched->getOldState())); } } diff --git a/src/Implementation/Builder.php b/src/Implementation/Builder.php index ef7665b..83e8067 100644 --- a/src/Implementation/Builder.php +++ b/src/Implementation/Builder.php @@ -8,6 +8,7 @@ use uuf6429\StateEngine\Exceptions\BuilderTransitionAlreadyDeclaredException; use uuf6429\StateEngine\Implementation\Entities\State; use uuf6429\StateEngine\Implementation\Entities\Transition; +use uuf6429\StateEngine\Implementation\Entities\TransitionWithData; use uuf6429\StateEngine\Implementation\Repositories\ArrayRepository; use uuf6429\StateEngine\Interfaces\EngineInterface; use uuf6429\StateEngine\Interfaces\StateAwareInterface; @@ -37,7 +38,7 @@ public static function create(): self return new static(); } - public static function stateMutator(callable $getter, callable $setter): StateAwareInterface + public static function makeStateMutator(callable $getter, callable $setter): StateAwareInterface { return new class ($getter, $setter) implements StateAwareInterface { private $getter; @@ -61,41 +62,66 @@ public function setState(StateInterface $newState): void }; } - public function addState(string $name, ?string $description = null): self + public function addState(StateInterface $state): self { - if (isset($this->states[$name])) { - throw new BuilderStateAlreadyDeclaredException($name); + $stateId = $state->getId(); + if (isset($this->states[$stateId])) { + throw new BuilderStateAlreadyDeclaredException($state); } - $this->states[$name] = new State($name, $description); + $this->states[$stateId] = $state; return $this; } - public function addTransition(string $oldStateName, string $newStateName, ?string $description = null): self + public function defState(string $name, ?string $description = null): self { - if (!isset($this->states[$oldStateName])) { - throw new BuilderStateNotDeclaredException($oldStateName); + return $this->addState(new State($name, $description)); + } + + public function addTransition(TransitionInterface $transition): self + { + if (!isset($this->states[$transition->getOldState()->getId()])) { + throw new BuilderStateNotDeclaredException($transition->getOldState()); } - if (!isset($this->states[$newStateName])) { - throw new BuilderStateNotDeclaredException($newStateName); + if (!isset($this->states[$transition->getNewState()->getId()])) { + throw new BuilderStateNotDeclaredException($transition->getNewState()); } - $transitionName = "($oldStateName) -> ($newStateName)"; - if (isset($this->transitions[$transitionName])) { - throw new BuilderTransitionAlreadyDeclaredException($oldStateName, $newStateName); + $transitionId = $transition->getId(); + if (isset($this->transitions[$transitionId])) { + throw new BuilderTransitionAlreadyDeclaredException($transition); } - $this->transitions[$transitionName] = new Transition( - $this->states[$oldStateName], - $this->states[$newStateName], - $description - ); + $this->transitions[$transitionId] = $transition; return $this; } + public function defTransition(string $oldStateName, string $newStateName, ?string $description = null): self + { + return $this->addTransition( + new Transition( + $this->states[$oldStateName] ?? new State($oldStateName), + $this->states[$newStateName] ?? new State($newStateName), + $description + ) + ); + } + + public function defDataTransition(string $oldStateName, array $data, string $newStateName, ?string $description = null): self + { + return $this->addTransition( + new TransitionWithData( + $this->states[$oldStateName] ?? new State($oldStateName), + $data, + $this->states[$newStateName] ?? new State($newStateName), + $description + ) + ); + } + /** * @return ArrayRepository */ @@ -106,10 +132,19 @@ public function getRepository(): TransitionRepositoryInterface /** * @param EventDispatcherInterface|null $eventDispatcher - * @return Engine + * @return StateEngine */ public function getEngine(?EventDispatcherInterface $eventDispatcher = null): EngineInterface { - return new Engine($this->getRepository(), $eventDispatcher); + return new StateEngine($this->getRepository(), $eventDispatcher); + } + + /** + * @param EventDispatcherInterface|null $eventDispatcher + * @return StateMachine + */ + public function getMachine(?EventDispatcherInterface $eventDispatcher = null): EngineInterface + { + return new StateMachine($this->getRepository(), $eventDispatcher); } } diff --git a/src/Implementation/Entities/State.php b/src/Implementation/Entities/State.php index af8d54c..3f00eb0 100644 --- a/src/Implementation/Entities/State.php +++ b/src/Implementation/Entities/State.php @@ -29,6 +29,16 @@ public function getDescription(): ?string public function equals($other): bool { return $other instanceof StateInterface - && $this->getName() === $other->getName(); + && $this->getId() === $other->getId(); + } + + public function getId(): string + { + return $this->name; + } + + public function __toString(): string + { + return $this->getId(); } } diff --git a/src/Implementation/Entities/Transition.php b/src/Implementation/Entities/Transition.php index 77e3aea..3fce1a3 100644 --- a/src/Implementation/Entities/Transition.php +++ b/src/Implementation/Entities/Transition.php @@ -37,7 +37,16 @@ public function getDescription(): ?string public function equals($other): bool { return $other instanceof TransitionInterface - && $this->getOldState()->equals($other->getOldState()) - && $this->getNewState()->equals($other->getNewState()); + && $this->getId() === $other->getId(); + } + + public function getId(): string + { + return "({$this->oldState->getId()}) -> ({$this->newState->getId()})"; + } + + public function __toString(): string + { + return "{$this->oldState->getId()} -> {$this->newState->getId()}"; } } diff --git a/src/Implementation/Entities/TransitionWithData.php b/src/Implementation/Entities/TransitionWithData.php new file mode 100644 index 0000000..89d6210 --- /dev/null +++ b/src/Implementation/Entities/TransitionWithData.php @@ -0,0 +1,34 @@ +data = $data; + ksort($this->data); + } + + public function getId(): string + { + return sprintf('(%s) %s', $this->getOldState()->getId(), sha1(serialize($this->data))); + } + + public function __toString(): string + { + return sprintf( + '%s (%s)', + $this->getOldState()->getId(), + serialize($this->data) + ); + } +} diff --git a/src/Implementation/Repositories/AbstractTraversable.php b/src/Implementation/Repositories/AbstractTraversable.php index 9e20cf3..ad47f0c 100644 --- a/src/Implementation/Repositories/AbstractTraversable.php +++ b/src/Implementation/Repositories/AbstractTraversable.php @@ -4,13 +4,13 @@ use IteratorAggregate; use Traversable; -use uuf6429\StateEngine\Implementation\Traits\HasTransition; +use uuf6429\StateEngine\Implementation\Traits\FindsTransition; use uuf6429\StateEngine\Implementation\Traits\StateTraversion; use uuf6429\StateEngine\Interfaces\TransitionRepositoryInterface; abstract class AbstractTraversable implements TransitionRepositoryInterface, IteratorAggregate { - use HasTransition, StateTraversion; + use FindsTransition, StateTraversion; public function all(): Traversable { diff --git a/src/Implementation/StateEngine.php b/src/Implementation/StateEngine.php new file mode 100644 index 0000000..f097821 --- /dev/null +++ b/src/Implementation/StateEngine.php @@ -0,0 +1,21 @@ +getState(), $newState); + $this->execute($item, $transition); + } +} diff --git a/src/Implementation/StateMachine.php b/src/Implementation/StateMachine.php new file mode 100644 index 0000000..29f51b4 --- /dev/null +++ b/src/Implementation/StateMachine.php @@ -0,0 +1,21 @@ +getState(), $input, new State('')); + $this->execute($item, $transition); + } +} diff --git a/src/Implementation/Traits/HasTransition.php b/src/Implementation/Traits/FindsTransition.php similarity index 53% rename from src/Implementation/Traits/HasTransition.php rename to src/Implementation/Traits/FindsTransition.php index 8aafe1c..5e17fb3 100644 --- a/src/Implementation/Traits/HasTransition.php +++ b/src/Implementation/Traits/FindsTransition.php @@ -8,16 +8,16 @@ /** * @mixin TransitionRepositoryInterface */ -trait HasTransition +trait FindsTransition { - public function has(TransitionInterface $transition): bool + public function find(TransitionInterface $search): ?TransitionInterface { - foreach ($this->all() as $testTransition) { - if ($transition->equals($testTransition)) { - return true; + foreach ($this->all() as $match) { + if ($search->equals($match)) { + return $match; } } - return false; + return null; } } diff --git a/src/Interfaces/EngineInterface.php b/src/Interfaces/EngineInterface.php index 97985d7..83a7f93 100644 --- a/src/Interfaces/EngineInterface.php +++ b/src/Interfaces/EngineInterface.php @@ -5,18 +5,10 @@ interface EngineInterface { /** - * Transition an item to a new state (given the new state). - * - * @param StateAwareInterface $item - * @param StateInterface $newState - */ - public function changeState(StateAwareInterface $item, StateInterface $newState): void; - - /** - * Transition an item to a new state (given a transition). + * Transition an item to a new state given a transition. * * @param StateAwareInterface $item * @param TransitionInterface $transition */ - public function applyTransition(StateAwareInterface $item, TransitionInterface $transition): void; + public function execute(StateAwareInterface $item, TransitionInterface $transition): void; } diff --git a/src/Interfaces/IdentifiableInterface.php b/src/Interfaces/IdentifiableInterface.php new file mode 100644 index 0000000..0918d23 --- /dev/null +++ b/src/Interfaces/IdentifiableInterface.php @@ -0,0 +1,13 @@ +repository = Builder::create() - // add states - ->addState('backlog', 'Backlog') - ->addState('analysis', 'Analysis') - ->addState('ready-for-dev', 'Ready for Dev') - ->addState('in-dev', 'In Dev') - ->addState('ready-for-qa', 'Ready for QA') - ->addState('in-qa', 'In QA') - ->addState('ready-for-release', 'Ready for Release') - ->addState('resolved', 'Resolved') - // add transitions - ->addTransition('backlog', 'analysis', 'Begin analysis') - ->addTransition('backlog', 'in-dev', 'Fast-track for development') - ->addTransition('analysis', 'ready-for-dev', 'Analysis complete') - ->addTransition('analysis', 'backlog', 'Return to backlog') - ->addTransition('ready-for-dev', 'analysis', 'Need more details') - ->addTransition('ready-for-dev', 'in-dev', 'Begin development') - ->addTransition('in-dev', 'ready-for-qa', 'Send to QA') - ->addTransition('in-dev', 'ready-for-release', 'Fast-track for release') - ->addTransition('in-dev', 'ready-for-dev', 'Stop development') - ->addTransition('ready-for-qa', 'in-qa', 'Begin testing') - ->addTransition('in-qa', 'ready-for-dev', 'QA Failed') - ->addTransition('in-qa', 'ready-for-release', 'QA Passed') - ->addTransition('ready-for-release', 'resolved', 'Released') - ->addTransition('resolved', 'backlog', 'Reopen') - // get repository - ->getRepository(); - - $this->engine = new Engine($this->repository, null); - } - - public function test_that_backlog_can_transition_to_analysis_or_in_dev(): void - { - $transitions = $this->repository->getForwardTransitions(new State('backlog')); - - $this->assertEquals( - ['analysis', 'in-dev'], - array_map( - static function (TransitionInterface $transition) { - return $transition->getNewState()->getName(); - }, - $transitions - ) - ); - } - - public function test_that_ready_for_release_happens_after_fast_track_or_passing_qa(): void - { - $transitions = $this->repository->getBackwardTransitions(new State('ready-for-release')); - - $this->assertEquals( - ['in-dev', 'in-qa'], - array_map( - static function (TransitionInterface $transition) { - return $transition->getOldState()->getName(); - }, - $transitions - ) - ); - } - - public function test_that_transitioning_from_in_dev_to_ready_for_qa_is_allowed(): void - { - $this->expectNotToPerformAssertions(); - - $item = $this->buildStatefulItem(new State('in-dev')); - $this->engine->changeState($item, new State('ready-for-qa')); - } - - public function test_that_transitioning_from_in_dev_to_in_qa_is_not_allowed(): void - { - $this->expectExceptionMessage('Cannot change state from in-dev to in-qa; no such transition was defined.'); - - $item = $this->buildStatefulItem(new State('in-dev')); - $this->engine->changeState($item, new State('in-qa')); - } - - public function test_that_plant_uml_generation_works(): void - { - $this->assertEquals( - [ - '@startuml', - '', - '(Backlog) --> (Analysis) : Begin analysis', - '(Backlog) --> (In Dev) : Fast-track for development', - '(Analysis) --> (Ready for Dev) : Analysis complete', - '(Analysis) --> (Backlog) : Return to backlog', - '(Ready for Dev) --> (Analysis) : Need more details', - '(Ready for Dev) --> (In Dev) : Begin development', - '(In Dev) --> (Ready for QA) : Send to QA', - '(In Dev) --> (Ready for Release) : Fast-track for release', - '(In Dev) --> (Ready for Dev) : Stop development', - '(Ready for QA) --> (In QA) : Begin testing', - '(In QA) --> (Ready for Dev) : QA Failed', - '(In QA) --> (Ready for Release) : QA Passed', - '(Ready for Release) --> (Resolved) : Released', - '(Resolved) --> (Backlog) : Reopen', - '', - '@enduml', - ], - explode(PHP_EOL, $this->repository->toPlantUML()) - ); - } - - public function test_that_the_engine_reads_and_writes_state(): void - { - $newState = new State('analysis'); - - $mock = $this->getMockBuilder(StateAwareInterface::class) - ->onlyMethods(['getState', 'setState']) - ->getMock(); - - $mock->expects($this->once()) - ->method('getState') - ->willReturn(new State('backlog')); - - $mock->expects($this->once()) - ->method('setState') - ->with($newState); - - $this->engine->changeState($mock, $newState); - } - - private function buildStatefulItem(State $initialState): StateAwareInterface - { - return new class($initialState) implements StateAwareInterface { - private StateInterface $state; - - public function __construct(StateInterface $initialState) - { - $this->state = $initialState; - } - - public function getState(): StateInterface - { - return $this->state; - } - - public function setState(StateInterface $newState): void - { - $this->state = $newState; - } - }; - } -} diff --git a/test/BuilderTest.php b/tests/BuilderTest.php similarity index 65% rename from test/BuilderTest.php rename to tests/BuilderTest.php index e530c33..c56d7b5 100644 --- a/test/BuilderTest.php +++ b/tests/BuilderTest.php @@ -14,29 +14,29 @@ protected function setUp(): void parent::setUp(); $this->builder = Builder::create() - ->addState('started') - ->addState('finished') - ->addTransition('started', 'finished'); + ->defState('started') + ->defState('finished') + ->defTransition('started', 'finished'); } public function test_that_states_cannot_be_redeclared(): void { $this->expectExceptionMessage('Cannot add state "started", it has already been declared.'); - $this->builder->addState('started'); + $this->builder->defState('started'); } public function test_that_transitions_cannot_be_redeclared(): void { - $this->expectExceptionMessage('Cannot add transition from "started" to "finished", it has already been declared.'); + $this->expectExceptionMessage('Cannot add transition "started -> finished", it has already been declared.'); - $this->builder->addTransition('started', 'finished'); + $this->builder->defTransition('started', 'finished'); } public function test_that_states_must_be_declared_before_transitions(): void { $this->expectExceptionMessage('Cannot use state "progressing", since it has not been declared yet.'); - $this->builder->addTransition('started', 'progressing'); + $this->builder->defTransition('started', 'progressing'); } } diff --git a/test/EventTest.php b/tests/EventTest.php similarity index 87% rename from test/EventTest.php rename to tests/EventTest.php index e748938..0c81511 100644 --- a/test/EventTest.php +++ b/tests/EventTest.php @@ -5,17 +5,17 @@ use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; use uuf6429\StateEngine\Implementation\Builder; -use uuf6429\StateEngine\Implementation\Engine; use uuf6429\StateEngine\Implementation\Entities\State; use uuf6429\StateEngine\Implementation\Events\StateChanged; use uuf6429\StateEngine\Implementation\Events\StateChanging; +use uuf6429\StateEngine\Interfaces\EngineInterface; use uuf6429\StateEngine\Interfaces\StateAwareInterface; use uuf6429\StateEngine\Interfaces\StateInterface; class EventTest extends TestCase { private EventDispatcherInterface $dispatcher; - private Engine $engine; + private EngineInterface $engine; protected function setUp(): void { @@ -26,9 +26,9 @@ protected function setUp(): void ->getMock(); $this->engine = Builder::create() - ->addState('started') - ->addState('finished') - ->addTransition('started', 'finished') + ->defState('started') + ->defState('finished') + ->defTransition('started', 'finished') ->getEngine($this->dispatcher); } @@ -60,7 +60,7 @@ public function setState(StateInterface $newState): void $statefulItem->state = $startedState; $this->engine->changeState($statefulItem, $finishedState); - $this->assertSame($finishedState, $statefulItem->state); + $this->assertEquals($finishedState, $statefulItem->state); $this->assertEquals( [ new StateChanging($statefulItem, $finishedState), diff --git a/tests/JiraIssueTest.php b/tests/JiraIssueTest.php new file mode 100644 index 0000000..78d65e8 --- /dev/null +++ b/tests/JiraIssueTest.php @@ -0,0 +1,142 @@ +defState('backlog', 'Backlog') + ->defState('analysis', 'Analysis') + ->defState('ready-for-dev', 'Ready for Dev') + ->defState('in-dev', 'In Dev') + ->defState('ready-for-qa', 'Ready for QA') + ->defState('in-qa', 'In QA') + ->defState('ready-for-release', 'Ready for Release') + ->defState('resolved', 'Resolved') + // make transitions + ->defTransition('backlog', 'analysis', 'Begin analysis') + ->defTransition('backlog', 'in-dev', 'Fast-track for development') + ->defTransition('analysis', 'ready-for-dev', 'Analysis complete') + ->defTransition('analysis', 'backlog', 'Return to backlog') + ->defTransition('ready-for-dev', 'analysis', 'Need more details') + ->defTransition('ready-for-dev', 'in-dev', 'Begin development') + ->defTransition('in-dev', 'ready-for-qa', 'Send to QA') + ->defTransition('in-dev', 'ready-for-release', 'Fast-track for release') + ->defTransition('in-dev', 'ready-for-dev', 'Stop development') + ->defTransition('ready-for-qa', 'in-qa', 'Begin testing') + ->defTransition('in-qa', 'ready-for-dev', 'QA Failed') + ->defTransition('in-qa', 'ready-for-release', 'QA Passed') + ->defTransition('ready-for-release', 'resolved', 'Released') + ->defTransition('resolved', 'backlog', 'Reopen'); + + $this->repository = $builder->getRepository(); + $this->engine = $builder->getEngine(); + } + + public function test_that_backlog_can_transition_to_analysis_or_in_dev(): void + { + $transitions = $this->repository->getForwardTransitions(new State('backlog')); + + $this->assertEquals( + ['analysis', 'in-dev'], + array_map( + static function (TransitionInterface $transition) { + return $transition->getNewState()->getName(); + }, + $transitions + ) + ); + } + + public function test_that_ready_for_release_happens_after_fast_track_or_passing_qa(): void + { + $transitions = $this->repository->getBackwardTransitions(new State('ready-for-release')); + + $this->assertEquals( + ['in-dev', 'in-qa'], + array_map( + static function (TransitionInterface $transition) { + return $transition->getOldState()->getName(); + }, + $transitions + ) + ); + } + + public function test_that_transitioning_from_in_dev_to_ready_for_qa_is_allowed(): void + { + $this->expectNotToPerformAssertions(); + + $item = new StatefulItem(new State('in-dev')); + $this->engine->changeState($item, new State('ready-for-qa')); + } + + public function test_that_transitioning_from_in_dev_to_in_qa_is_not_allowed(): void + { + $this->expectExceptionMessage('Cannot apply transition "in-dev -> in-qa"; no such transition was defined.'); + + $item = new StatefulItem(new State('in-dev')); + $this->engine->changeState($item, new State('in-qa')); + } + + public function test_that_plant_uml_generation_works(): void + { + $this->assertEquals( + [ + 0 => '@startuml', + 1 => '', + 2 => '(Backlog) --> (Analysis) : Begin analysis', + 3 => '(Backlog) --> (In Dev) : Fast-track for development', + 4 => '(Analysis) --> (Ready for Dev) : Analysis complete', + 5 => '(Analysis) --> (Backlog) : Return to backlog', + 6 => '(Ready for Dev) --> (Analysis) : Need more details', + 7 => '(Ready for Dev) --> (In Dev) : Begin development', + 8 => '(In Dev) --> (Ready for QA) : Send to QA', + 9 => '(In Dev) --> (Ready for Release) : Fast-track for release', + 10 => '(In Dev) --> (Ready for Dev) : Stop development', + 11 => '(Ready for QA) --> (In QA) : Begin testing', + 12 => '(In QA) --> (Ready for Dev) : QA Failed', + 13 => '(In QA) --> (Ready for Release) : QA Passed', + 14 => '(Ready for Release) --> (Resolved) : Released', + 15 => '(Resolved) --> (Backlog) : Reopen', + 16 => '', + 17 => '@enduml', + ], + explode(PHP_EOL, $this->repository->toPlantUML()) + ); + } + + public function test_that_the_engine_reads_and_writes_state(): void + { + $newState = new State('analysis'); + + $mock = $this->getMockBuilder(StateAwareInterface::class) + ->onlyMethods(['getState', 'setState']) + ->getMock(); + + $mock->expects($this->once()) + ->method('getState') + ->willReturn(new State('backlog')); + + $mock->expects($this->once()) + ->method('setState'); + + $this->engine->changeState($mock, $newState); + } +} diff --git a/tests/StatefulItem.php b/tests/StatefulItem.php new file mode 100644 index 0000000..6944aa1 --- /dev/null +++ b/tests/StatefulItem.php @@ -0,0 +1,26 @@ +state = $initialState; + } + + public function getState(): StateInterface + { + return $this->state; + } + + public function setState(StateInterface $newState): void + { + $this->state = $newState; + } +} diff --git a/tests/TurnstileGateTest.php b/tests/TurnstileGateTest.php new file mode 100644 index 0000000..6fab1ad --- /dev/null +++ b/tests/TurnstileGateTest.php @@ -0,0 +1,62 @@ +defState('locked', 'Impassable') + ->defState('open', 'Passable') + // make transitions + ->defDataTransition('locked', ['insert_coin'], 'open', 'Coin placed') + ->defDataTransition('open', ['walk_through'], 'locked', 'Person walks through'); + + $this->repository = $builder->getRepository(); + $this->machine = $builder->getMachine(); + } + + public function test_that_user_cannot_walk_when_locked(): void + { + $this->expectExceptionMessage('Cannot apply transition "locked (a:1:{i:0;s:12:"walk_through";})"; no such transition was defined.'); + + $item = new StatefulItem(new State('locked')); + $this->machine->processInput($item, ['walk_through']); + } + + public function test_turnstile_opens_after_paying(): void + { + $item = new StatefulItem(new State('locked')); + $this->machine->processInput($item, ['insert_coin']); + + $this->assertSame('open', $item->getState()->getName()); + } + + public function test_that_plant_uml_generation_works(): void + { + $this->assertEquals( + [ + 0 => '@startuml', + 1 => '', + 2 => '(Impassable) --> (Passable) : Coin placed', + 3 => '(Passable) --> (Impassable) : Person walks through', + 4 => '', + 5 => '@enduml', + ], + explode(PHP_EOL, $this->repository->toPlantUML()) + ); + } +}