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())
+ );
+ }
+}