diff --git a/.gitattributes b/.gitattributes index 84c7add..14c3c35 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,3 @@ /Tests export-ignore /phpunit.xml.dist export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4689c4d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/symfony + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/.github/workflows/close-pull-request.yml b/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..e55b478 --- /dev/null +++ b/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/symfony + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/Attribute/AsAnnounceListener.php b/Attribute/AsAnnounceListener.php new file mode 100644 index 0000000..8afa4ca --- /dev/null +++ b/Attribute/AsAnnounceListener.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Attribute; + +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +/** + * Defines a listener for the "announce" event of a workflow. + * + * @author Grégoire Pineau + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class AsAnnounceListener extends AsEventListener +{ + use BuildEventNameTrait; + + /** + * @param string|null $workflow The id of the workflow to listen to + * @param string|null $transition The transition name to which the listener listens to + * @param string|null $method The method to run when the listened event is triggered + * @param int $priority The priority of this listener if several are declared for the same transition + * @param string|null $dispatcher The service id of the event dispatcher to listen to + */ + public function __construct( + ?string $workflow = null, + ?string $transition = null, + ?string $method = null, + int $priority = 0, + ?string $dispatcher = null, + ) { + parent::__construct($this->buildEventName('announce', 'transition', $workflow, $transition), $method, $priority, $dispatcher); + } +} diff --git a/Attribute/AsCompletedListener.php b/Attribute/AsCompletedListener.php new file mode 100644 index 0000000..82bfe9d --- /dev/null +++ b/Attribute/AsCompletedListener.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Attribute; + +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +/** + * Defines a listener for the "completed" event of a workflow. + * + * @author Grégoire Pineau + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class AsCompletedListener extends AsEventListener +{ + use BuildEventNameTrait; + + /** + * @param string|null $workflow The id of the workflow to listen to + * @param string|null $transition The transition name to which the listener listens to + * @param string|null $method The method to run when the listened event is triggered + * @param int $priority The priority of this listener if several are declared for the same transition + * @param string|null $dispatcher The service id of the event dispatcher to listen to + */ + public function __construct( + ?string $workflow = null, + ?string $transition = null, + ?string $method = null, + int $priority = 0, + ?string $dispatcher = null, + ) { + parent::__construct($this->buildEventName('completed', 'transition', $workflow, $transition), $method, $priority, $dispatcher); + } +} diff --git a/Attribute/AsEnterListener.php b/Attribute/AsEnterListener.php new file mode 100644 index 0000000..97e7917 --- /dev/null +++ b/Attribute/AsEnterListener.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Attribute; + +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +/** + * Defines a listener for the "enter" event of a workflow. + * + * @author Grégoire Pineau + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class AsEnterListener extends AsEventListener +{ + use BuildEventNameTrait; + + /** + * @param string|null $workflow The id of the workflow to listen to + * @param string|null $place The place name to which the listener listens to + * @param string|null $method The method to run when the listened event is triggered + * @param int $priority The priority of this listener if several are declared for the same place + * @param string|null $dispatcher The service id of the event dispatcher to listen to + */ + public function __construct( + ?string $workflow = null, + ?string $place = null, + ?string $method = null, + int $priority = 0, + ?string $dispatcher = null, + ) { + parent::__construct($this->buildEventName('enter', 'place', $workflow, $place), $method, $priority, $dispatcher); + } +} diff --git a/Attribute/AsEnteredListener.php b/Attribute/AsEnteredListener.php new file mode 100644 index 0000000..0824628 --- /dev/null +++ b/Attribute/AsEnteredListener.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Attribute; + +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +/** + * Defines a listener for the "entered" event of a workflow. + * + * @author Grégoire Pineau + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class AsEnteredListener extends AsEventListener +{ + use BuildEventNameTrait; + + /** + * @param string|null $workflow The id of the workflow to listen to + * @param string|null $place The place name to which the listener listens to + * @param string|null $method The method to run when the listened event is triggered + * @param int $priority The priority of this listener if several are declared for the same place + * @param string|null $dispatcher The service id of the event dispatcher to listen to + */ + public function __construct( + ?string $workflow = null, + ?string $place = null, + ?string $method = null, + int $priority = 0, + ?string $dispatcher = null, + ) { + parent::__construct($this->buildEventName('entered', 'place', $workflow, $place), $method, $priority, $dispatcher); + } +} diff --git a/Attribute/AsGuardListener.php b/Attribute/AsGuardListener.php new file mode 100644 index 0000000..e2e783f --- /dev/null +++ b/Attribute/AsGuardListener.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Attribute; + +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +/** + * Defines a listener for a guard event of a workflow. + * + * @author Grégoire Pineau + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class AsGuardListener extends AsEventListener +{ + use BuildEventNameTrait; + + /** + * @param string|null $workflow The id of the workflow to listen to + * @param string|null $transition The transition name to which the listener listens to + * @param string|null $method The method to run when the listened event is triggered + * @param int $priority The priority of this listener if several are declared for the same transition + * @param string|null $dispatcher The service id of the event dispatcher to listen to + */ + public function __construct( + ?string $workflow = null, + ?string $transition = null, + ?string $method = null, + int $priority = 0, + ?string $dispatcher = null, + ) { + parent::__construct($this->buildEventName('guard', 'transition', $workflow, $transition), $method, $priority, $dispatcher); + } +} diff --git a/Attribute/AsLeaveListener.php b/Attribute/AsLeaveListener.php new file mode 100644 index 0000000..3ef6b4d --- /dev/null +++ b/Attribute/AsLeaveListener.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Attribute; + +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +/** + * Defines a listener for the "leave" event of a workflow. + * + * @author Grégoire Pineau + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class AsLeaveListener extends AsEventListener +{ + use BuildEventNameTrait; + + /** + * @param string|null $workflow The id of the workflow to listen to + * @param string|null $place The place name to which the listener listens to + * @param string|null $method The method to run when the listened event is triggered + * @param int $priority The priority of this listener if several are declared for the same place + * @param string|null $dispatcher The service id of the event dispatcher to listen to + */ + public function __construct( + ?string $workflow = null, + ?string $place = null, + ?string $method = null, + int $priority = 0, + ?string $dispatcher = null, + ) { + parent::__construct($this->buildEventName('leave', 'place', $workflow, $place), $method, $priority, $dispatcher); + } +} diff --git a/Attribute/AsTransitionListener.php b/Attribute/AsTransitionListener.php new file mode 100644 index 0000000..dc49749 --- /dev/null +++ b/Attribute/AsTransitionListener.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Attribute; + +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +/** + * Defines a listener for a transition event of a workflow. + * + * @author Grégoire Pineau + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class AsTransitionListener extends AsEventListener +{ + use BuildEventNameTrait; + + /** + * @param string|null $workflow The id of the workflow to listen to + * @param string|null $transition The transition name to which the listener listens to + * @param string|null $method The method to run when the listened event is triggered + * @param int $priority The priority of this listener if several are declared for the same transition + * @param string|null $dispatcher The service id of the event dispatcher to listen to + */ + public function __construct( + ?string $workflow = null, + ?string $transition = null, + ?string $method = null, + int $priority = 0, + ?string $dispatcher = null, + ) { + parent::__construct($this->buildEventName('transition', 'transition', $workflow, $transition), $method, $priority, $dispatcher); + } +} diff --git a/Attribute/BuildEventNameTrait.php b/Attribute/BuildEventNameTrait.php new file mode 100644 index 0000000..d6d3c26 --- /dev/null +++ b/Attribute/BuildEventNameTrait.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Attribute; + +use Symfony\Component\Workflow\Exception\LogicException; + +/** + * @author Grégoire Pineau + * + * @internal + */ +trait BuildEventNameTrait +{ + private static function buildEventName(string $keyword, string $argument, ?string $workflow = null, ?string $node = null): string + { + if (null === $workflow) { + if (null !== $node) { + throw new LogicException(\sprintf('The "%s" argument of "%s" cannot be used without a "workflow" argument.', $argument, self::class)); + } + + return \sprintf('workflow.%s', $keyword); + } + + if (null === $node) { + return \sprintf('workflow.%s.%s', $workflow, $keyword); + } + + return \sprintf('workflow.%s.%s.%s', $workflow, $keyword, $node); + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d44a3b..5a37ead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,61 @@ CHANGELOG ========= +7.3 +--- + + * Deprecate `Event::getWorkflow()` method + +7.1 +--- + + * Add method `getEnabledTransition()` to `WorkflowInterface` + * Automatically register places from transitions + * Add support for workflows that need to store many tokens in the marking + * Add method `getName()` in event classes to build event names in subscribers + +7.0 +--- + + * Require explicit argument when calling `Definition::setInitialPlaces()` + * `GuardEvent::getContext()` method has been removed. Method was not supposed to be called within guard event listeners as it always returned an empty array anyway. + * Remove `GuardEvent::getContext()` method without replacement + +6.4 +--- + + * Add `with-metadata` option to the `workflow:dump` command to include places, + transitions and workflow's metadata into dumped graph + * Add support for storing marking in a property + * Add a profiler + * Add support for multiline descriptions in PlantUML diagrams + * Add PHP attributes to register listeners and guards + * Deprecate `GuardEvent::getContext()` method that will be removed in 7.0 + * Revert: Mark `Symfony\Component\Workflow\Registry` as internal + * Add `WorkflowGuardListenerPass` (moved from `FrameworkBundle`) + +6.2 +--- + + * Mark `Symfony\Component\Workflow\Registry` as internal + * Deprecate calling `Definition::setInitialPlaces()` without arguments + +6.0 +--- + + * Remove `InvalidTokenConfigurationException` + +5.4 +--- + + * Add support for getting updated context after a transition + +5.3 +--- + + * Deprecate `InvalidTokenConfigurationException` + * Added `MermaidDumper` to dump Workflow graphs in the Mermaid.js flowchart format + 5.2.0 ----- diff --git a/DataCollector/WorkflowDataCollector.php b/DataCollector/WorkflowDataCollector.php new file mode 100644 index 0000000..6ce732b --- /dev/null +++ b/DataCollector/WorkflowDataCollector.php @@ -0,0 +1,241 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\DataCollector; + +use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\DataCollector\DataCollector; +use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; +use Symfony\Component\VarDumper\Caster\Caster; +use Symfony\Component\VarDumper\Cloner\Stub; +use Symfony\Component\Workflow\Debug\TraceableWorkflow; +use Symfony\Component\Workflow\Dumper\MermaidDumper; +use Symfony\Component\Workflow\EventListener\GuardExpression; +use Symfony\Component\Workflow\EventListener\GuardListener; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Transition; +use Symfony\Component\Workflow\TransitionBlocker; +use Symfony\Component\Workflow\WorkflowInterface; + +/** + * @author Grégoire Pineau + */ +final class WorkflowDataCollector extends DataCollector implements LateDataCollectorInterface +{ + public function __construct( + private readonly iterable $workflows, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly FileLinkFormatter $fileLinkFormatter, + ) { + } + + public function collect(Request $request, Response $response, ?\Throwable $exception = null): void + { + } + + public function lateCollect(): void + { + foreach ($this->workflows as $workflow) { + $calls = []; + if ($workflow instanceof TraceableWorkflow) { + $calls = $this->cloneVar($workflow->getCalls()); + } + + // We always use a workflow type because we want to mermaid to + // create a node for transitions + $dumper = new MermaidDumper(MermaidDumper::TRANSITION_TYPE_WORKFLOW); + $this->data['workflows'][$workflow->getName()] = [ + 'dump' => $dumper->dump($workflow->getDefinition()), + 'calls' => $calls, + 'listeners' => $this->getEventListeners($workflow), + ]; + } + } + + public function getName(): string + { + return 'workflow'; + } + + public function reset(): void + { + $this->data = []; + } + + public function getWorkflows(): array + { + return $this->data['workflows'] ?? []; + } + + public function getCallsCount(): int + { + $i = 0; + foreach ($this->getWorkflows() as $workflow) { + $i += \count($workflow['calls']); + } + + return $i; + } + + public function hash(string $string): string + { + return hash('xxh128', $string); + } + + public function buildMermaidLiveLink(string $name): string + { + $payload = [ + 'code' => $this->data['workflows'][$name]['dump'], + 'mermaid' => '{"theme": "default"}', + 'autoSync' => false, + ]; + + $compressed = zlib_encode(json_encode($payload), \ZLIB_ENCODING_DEFLATE); + + $suffix = rtrim(strtr(base64_encode($compressed), '+/', '-_'), '='); + + return "https://mermaid.live/edit#pako:{$suffix}"; + } + + protected function getCasters(): array + { + return [ + ...parent::getCasters(), + TransitionBlocker::class => static function ($v, array $a, Stub $s) { + unset($a[\sprintf(Caster::PATTERN_PRIVATE, $v::class, 'code')]); + unset($a[\sprintf(Caster::PATTERN_PRIVATE, $v::class, 'parameters')]); + + $s->cut += 2; + + return $a; + }, + Marking::class => static function ($v, array $a) { + $a[Caster::PREFIX_VIRTUAL.'.places'] = array_keys($v->getPlaces()); + + return $a; + }, + ]; + } + + private function getEventListeners(WorkflowInterface $workflow): array + { + $listeners = []; + $placeId = 0; + foreach ($workflow->getDefinition()->getPlaces() as $place) { + $eventNames = []; + $subEventNames = [ + 'leave', + 'enter', + 'entered', + ]; + foreach ($subEventNames as $subEventName) { + $eventNames[] = \sprintf('workflow.%s', $subEventName); + $eventNames[] = \sprintf('workflow.%s.%s', $workflow->getName(), $subEventName); + $eventNames[] = \sprintf('workflow.%s.%s.%s', $workflow->getName(), $subEventName, $place); + } + foreach ($eventNames as $eventName) { + foreach ($this->eventDispatcher->getListeners($eventName) as $listener) { + $listeners["place{$placeId}"][$eventName][] = $this->summarizeListener($listener); + } + } + + ++$placeId; + } + + foreach ($workflow->getDefinition()->getTransitions() as $transitionId => $transition) { + $eventNames = []; + $subEventNames = [ + 'guard', + 'transition', + 'completed', + 'announce', + ]; + foreach ($subEventNames as $subEventName) { + $eventNames[] = \sprintf('workflow.%s', $subEventName); + $eventNames[] = \sprintf('workflow.%s.%s', $workflow->getName(), $subEventName); + $eventNames[] = \sprintf('workflow.%s.%s.%s', $workflow->getName(), $subEventName, $transition->getName()); + } + foreach ($eventNames as $eventName) { + foreach ($this->eventDispatcher->getListeners($eventName) as $listener) { + $listeners["transition{$transitionId}"][$eventName][] = $this->summarizeListener($listener, $eventName, $transition); + } + } + } + + return $listeners; + } + + private function summarizeListener(callable $callable, ?string $eventName = null, ?Transition $transition = null): array + { + $extra = []; + + if ($callable instanceof \Closure) { + $r = new \ReflectionFunction($callable); + if ($r->isAnonymous()) { + $title = (string) $r; + } elseif ($class = $r->getClosureCalledClass()) { + $title = $class->name.'::'.$r->name.'()'; + } else { + $title = $r->name; + } + } elseif (\is_string($callable)) { + $title = $callable.'()'; + $r = new \ReflectionFunction($callable); + } elseif (\is_object($callable) && method_exists($callable, '__invoke')) { + $r = new \ReflectionMethod($callable, '__invoke'); + $title = $callable::class.'::__invoke()'; + } elseif (\is_array($callable)) { + if ($callable[0] instanceof GuardListener) { + if (null === $eventName || null === $transition) { + throw new \LogicException('Missing event name or transition.'); + } + $extra['guardExpressions'] = $this->extractGuardExpressions($callable[0], $eventName, $transition); + } + $r = new \ReflectionMethod($callable[0], $callable[1]); + $title = (\is_string($callable[0]) ? $callable[0] : \get_class($callable[0])).'::'.$callable[1].'()'; + } else { + throw new \RuntimeException('Unknown callable type.'); + } + + $file = null; + if ($r->isUserDefined()) { + $file = $this->fileLinkFormatter->format($r->getFileName(), $r->getStartLine()); + } + + return [ + 'title' => $title, + 'file' => $file, + ...$extra, + ]; + } + + private function extractGuardExpressions(GuardListener $listener, string $eventName, Transition $transition): array + { + $configuration = (new \ReflectionProperty(GuardListener::class, 'configuration'))->getValue($listener); + + $expressions = []; + foreach ($configuration[$eventName] as $guard) { + if ($guard instanceof GuardExpression) { + if ($guard->getTransition() !== $transition) { + continue; + } + $expressions[] = $guard->getExpression(); + } else { + $expressions[] = $guard; + } + } + + return $expressions; + } +} diff --git a/Debug/TraceableWorkflow.php b/Debug/TraceableWorkflow.php new file mode 100644 index 0000000..c783e63 --- /dev/null +++ b/Debug/TraceableWorkflow.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Debug; + +use Symfony\Component\Stopwatch\Stopwatch; +use Symfony\Component\Workflow\Definition; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface; +use Symfony\Component\Workflow\Metadata\MetadataStoreInterface; +use Symfony\Component\Workflow\Transition; +use Symfony\Component\Workflow\TransitionBlockerList; +use Symfony\Component\Workflow\WorkflowInterface; + +/** + * @author Grégoire Pineau + */ +class TraceableWorkflow implements WorkflowInterface +{ + private array $calls = []; + + public function __construct( + private readonly WorkflowInterface $workflow, + private readonly Stopwatch $stopwatch, + protected readonly ?\Closure $disabled = null, + ) { + } + + public function getMarking(object $subject, array $context = []): Marking + { + return $this->callInner(__FUNCTION__, \func_get_args()); + } + + public function can(object $subject, string $transitionName): bool + { + return $this->callInner(__FUNCTION__, \func_get_args()); + } + + public function buildTransitionBlockerList(object $subject, string $transitionName): TransitionBlockerList + { + return $this->callInner(__FUNCTION__, \func_get_args()); + } + + public function apply(object $subject, string $transitionName, array $context = []): Marking + { + return $this->callInner(__FUNCTION__, \func_get_args()); + } + + public function getEnabledTransitions(object $subject): array + { + return $this->callInner(__FUNCTION__, \func_get_args()); + } + + public function getEnabledTransition(object $subject, string $name): ?Transition + { + return $this->callInner(__FUNCTION__, \func_get_args()); + } + + public function getName(): string + { + return $this->workflow->getName(); + } + + public function getDefinition(): Definition + { + return $this->workflow->getDefinition(); + } + + public function getMarkingStore(): MarkingStoreInterface + { + return $this->workflow->getMarkingStore(); + } + + public function getMetadataStore(): MetadataStoreInterface + { + return $this->workflow->getMetadataStore(); + } + + public function getCalls(): array + { + return $this->calls; + } + + private function callInner(string $method, array $args): mixed + { + if ($this->disabled?->__invoke()) { + return $this->workflow->{$method}(...$args); + } + $sMethod = $this->workflow::class.'::'.$method; + $this->stopwatch->start($sMethod, 'workflow'); + + $previousMarking = null; + if ('apply' === $method) { + try { + $previousMarking = $this->workflow->getMarking($args[0]); + } catch (\Throwable) { + } + } + + try { + $return = $this->workflow->{$method}(...$args); + + $this->calls[] = [ + 'method' => $method, + 'duration' => $this->stopwatch->stop($sMethod)->getDuration(), + 'args' => $args, + 'previousMarking' => $previousMarking ?? null, + 'return' => $return, + ]; + + return $return; + } catch (\Throwable $exception) { + $this->calls[] = [ + 'method' => $method, + 'duration' => $this->stopwatch->stop($sMethod)->getDuration(), + 'args' => $args, + 'previousMarking' => $previousMarking ?? null, + 'exception' => $exception, + ]; + + throw $exception; + } + } +} diff --git a/Definition.php b/Definition.php index 210e756..0b5697b 100644 --- a/Definition.php +++ b/Definition.php @@ -22,17 +22,17 @@ */ final class Definition { - private $places = []; - private $transitions = []; - private $initialPlaces = []; - private $metadataStore; + private array $places = []; + private array $transitions = []; + private array $initialPlaces = []; + private MetadataStoreInterface $metadataStore; /** * @param string[] $places * @param Transition[] $transitions * @param string|string[]|null $initialPlaces */ - public function __construct(array $places, array $transitions, $initialPlaces = null, MetadataStoreInterface $metadataStore = null) + public function __construct(array $places, array $transitions, string|array|null $initialPlaces = null, ?MetadataStoreInterface $metadataStore = null) { foreach ($places as $place) { $this->addPlace($place); @@ -44,7 +44,7 @@ public function __construct(array $places, array $transitions, $initialPlaces = $this->setInitialPlaces($initialPlaces); - $this->metadataStore = $metadataStore ?: new InMemoryMetadataStore(); + $this->metadataStore = $metadataStore ?? new InMemoryMetadataStore(); } /** @@ -76,7 +76,7 @@ public function getMetadataStore(): MetadataStoreInterface return $this->metadataStore; } - private function setInitialPlaces($places = null) + private function setInitialPlaces(string|array|null $places): void { if (!$places) { return; @@ -86,14 +86,14 @@ private function setInitialPlaces($places = null) foreach ($places as $place) { if (!isset($this->places[$place])) { - throw new LogicException(sprintf('Place "%s" cannot be the initial place as it does not exist.', $place)); + throw new LogicException(\sprintf('Place "%s" cannot be the initial place as it does not exist.', $place)); } } $this->initialPlaces = $places; } - private function addPlace(string $place) + private function addPlace(string $place): void { if (!\count($this->places)) { $this->initialPlaces = [$place]; @@ -102,19 +102,17 @@ private function addPlace(string $place) $this->places[$place] = $place; } - private function addTransition(Transition $transition) + private function addTransition(Transition $transition): void { - $name = $transition->getName(); - foreach ($transition->getFroms() as $from) { - if (!isset($this->places[$from])) { - throw new LogicException(sprintf('Place "%s" referenced in transition "%s" does not exist.', $from, $name)); + if (!\array_key_exists($from, $this->places)) { + $this->addPlace($from); } } foreach ($transition->getTos() as $to) { - if (!isset($this->places[$to])) { - throw new LogicException(sprintf('Place "%s" referenced in transition "%s" does not exist.', $to, $name)); + if (!\array_key_exists($to, $this->places)) { + $this->addPlace($to); } } diff --git a/DefinitionBuilder.php b/DefinitionBuilder.php index 19e9067..5b7a15b 100644 --- a/DefinitionBuilder.php +++ b/DefinitionBuilder.php @@ -22,10 +22,10 @@ */ class DefinitionBuilder { - private $places = []; - private $transitions = []; - private $initialPlaces; - private $metadataStore; + private array $places = []; + private array $transitions = []; + private string|array|null $initialPlaces = null; + private ?MetadataStoreInterface $metadataStore = null; /** * @param string[] $places @@ -37,10 +37,7 @@ public function __construct(array $places = [], array $transitions = []) $this->addTransitions($transitions); } - /** - * @return Definition - */ - public function build() + public function build(): Definition { return new Definition($this->places, $this->transitions, $this->initialPlaces, $this->metadataStore); } @@ -50,7 +47,7 @@ public function build() * * @return $this */ - public function clear() + public function clear(): static { $this->places = []; $this->transitions = []; @@ -65,7 +62,7 @@ public function clear() * * @return $this */ - public function setInitialPlaces($initialPlaces) + public function setInitialPlaces(string|array|null $initialPlaces): static { $this->initialPlaces = $initialPlaces; @@ -75,7 +72,7 @@ public function setInitialPlaces($initialPlaces) /** * @return $this */ - public function addPlace(string $place) + public function addPlace(string $place): static { if (!$this->places) { $this->initialPlaces = $place; @@ -91,7 +88,7 @@ public function addPlace(string $place) * * @return $this */ - public function addPlaces(array $places) + public function addPlaces(array $places): static { foreach ($places as $place) { $this->addPlace($place); @@ -105,7 +102,7 @@ public function addPlaces(array $places) * * @return $this */ - public function addTransitions(array $transitions) + public function addTransitions(array $transitions): static { foreach ($transitions as $transition) { $this->addTransition($transition); @@ -117,7 +114,7 @@ public function addTransitions(array $transitions) /** * @return $this */ - public function addTransition(Transition $transition) + public function addTransition(Transition $transition): static { $this->transitions[] = $transition; @@ -127,7 +124,7 @@ public function addTransition(Transition $transition) /** * @return $this */ - public function setMetadataStore(MetadataStoreInterface $metadataStore) + public function setMetadataStore(MetadataStoreInterface $metadataStore): static { $this->metadataStore = $metadataStore; diff --git a/DependencyInjection/WorkflowDebugPass.php b/DependencyInjection/WorkflowDebugPass.php new file mode 100644 index 0000000..042aaba --- /dev/null +++ b/DependencyInjection/WorkflowDebugPass.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\DependencyInjection; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Workflow\Debug\TraceableWorkflow; + +/** + * Adds all configured security voters to the access decision manager. + * + * @author Grégoire Pineau + */ +class WorkflowDebugPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + foreach ($container->findTaggedServiceIds('workflow') as $id => $attributes) { + $container->register("debug.{$id}", TraceableWorkflow::class) + ->setDecoratedService($id) + ->setArguments([ + new Reference("debug.{$id}.inner"), + new Reference('debug.stopwatch'), + new Reference('profiler.is_disabled_state_checker', ContainerBuilder::IGNORE_ON_INVALID_REFERENCE), + ]); + } + } +} diff --git a/DependencyInjection/WorkflowGuardListenerPass.php b/DependencyInjection/WorkflowGuardListenerPass.php new file mode 100644 index 0000000..ccf00f0 --- /dev/null +++ b/DependencyInjection/WorkflowGuardListenerPass.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\DependencyInjection; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\LogicException; + +/** + * @author Christian Flothmann + * @author Grégoire Pineau + */ +class WorkflowGuardListenerPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!$container->hasParameter('workflow.has_guard_listeners')) { + return; + } + + $container->getParameterBag()->remove('workflow.has_guard_listeners'); + + $servicesNeeded = [ + 'security.token_storage', + 'security.authorization_checker', + 'security.authentication.trust_resolver', + 'security.role_hierarchy', + ]; + + foreach ($servicesNeeded as $service) { + if (!$container->has($service)) { + throw new LogicException(\sprintf('The "%s" service is needed to be able to use the workflow guard listener.', $service)); + } + } + } +} diff --git a/DependencyInjection/WorkflowValidatorPass.php b/DependencyInjection/WorkflowValidatorPass.php new file mode 100644 index 0000000..d1e4622 --- /dev/null +++ b/DependencyInjection/WorkflowValidatorPass.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\DependencyInjection; + +use Symfony\Component\Config\Resource\FileResource; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * @author Grégoire Pineau + */ +class WorkflowValidatorPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + foreach ($container->findTaggedServiceIds('workflow') as $attributes) { + foreach ($attributes as $attribute) { + foreach ($attribute['definition_validators'] ?? [] as $validatorClass) { + $container->addResource(new FileResource($container->getReflectionClass($validatorClass)->getFileName())); + + $realDefinition = $container->get($attribute['definition_id'] ?? throw new \LogicException('The "definition_id" attribute is required.')); + (new $validatorClass())->validate($realDefinition, $attribute['name'] ?? throw new \LogicException('The "name" attribute is required.')); + } + } + } + } +} diff --git a/Dumper/DumperInterface.php b/Dumper/DumperInterface.php index e1d8c7d..b39e0e9 100644 --- a/Dumper/DumperInterface.php +++ b/Dumper/DumperInterface.php @@ -24,8 +24,6 @@ interface DumperInterface { /** * Dumps a workflow definition. - * - * @return string The representation of the workflow */ - public function dump(Definition $definition, Marking $marking = null, array $options = []); + public function dump(Definition $definition, ?Marking $marking = null, array $options = []): string; } diff --git a/Dumper/GraphvizDumper.php b/Dumper/GraphvizDumper.php index 56623b5..ad7b0c2 100644 --- a/Dumper/GraphvizDumper.php +++ b/Dumper/GraphvizDumper.php @@ -27,15 +27,13 @@ class GraphvizDumper implements DumperInterface { // All values should be strings - protected static $defaultOptions = [ + protected static array $defaultOptions = [ 'graph' => ['ratio' => 'compress', 'rankdir' => 'LR'], 'node' => ['fontsize' => '9', 'fontname' => 'Arial', 'color' => '#333333', 'fillcolor' => 'lightblue', 'fixedsize' => 'false', 'width' => '1'], 'edge' => ['fontsize' => '9', 'fontname' => 'Arial', 'color' => '#333333', 'arrowhead' => 'normal', 'arrowsize' => '0.5'], ]; /** - * {@inheritdoc} - * * Dumps the workflow as a graphviz graph. * * Available options: @@ -44,17 +42,21 @@ class GraphvizDumper implements DumperInterface * * node: The default options for nodes (places + transitions) * * edge: The default options for edges */ - public function dump(Definition $definition, Marking $marking = null, array $options = []) + public function dump(Definition $definition, ?Marking $marking = null, array $options = []): string { - $places = $this->findPlaces($definition, $marking); - $transitions = $this->findTransitions($definition); + $withMetadata = $options['with-metadata'] ?? false; + + $places = $this->findPlaces($definition, $withMetadata, $marking); + $transitions = $this->findTransitions($definition, $withMetadata); $edges = $this->findEdges($definition); $options = array_replace_recursive(self::$defaultOptions, $options); - return $this->startDot($options) - .$this->addPlaces($places) - .$this->addTransitions($transitions) + $label = $this->formatLabel($definition, $withMetadata, $options); + + return $this->startDot($options, $label) + .$this->addPlaces($places, $withMetadata) + .$this->addTransitions($transitions, $withMetadata) .$this->addEdges($edges) .$this->endDot(); } @@ -62,7 +64,7 @@ public function dump(Definition $definition, Marking $marking = null, array $opt /** * @internal */ - protected function findPlaces(Definition $definition, Marking $marking = null): array + protected function findPlaces(Definition $definition, bool $withMetadata, ?Marking $marking = null): array { $workflowMetadata = $definition->getMetadataStore(); @@ -73,7 +75,7 @@ protected function findPlaces(Definition $definition, Marking $marking = null): if (\in_array($place, $definition->getInitialPlaces(), true)) { $attributes['style'] = 'filled'; } - if ($marking && $marking->has($place)) { + if ($marking?->has($place)) { $attributes['color'] = '#FF0000'; $attributes['shape'] = 'doublecircle'; } @@ -82,9 +84,16 @@ protected function findPlaces(Definition $definition, Marking $marking = null): $attributes['style'] = 'filled'; $attributes['fillcolor'] = $backgroundColor; } + if ($withMetadata) { + $attributes['metadata'] = $workflowMetadata->getPlaceMetadata($place); + } $label = $workflowMetadata->getMetadata('label', $place); if (null !== $label) { $attributes['name'] = $label; + if ($withMetadata) { + // Don't include label in metadata if already used as name + unset($attributes['metadata']['label']); + } } $places[$place] = [ 'attributes' => $attributes, @@ -97,7 +106,7 @@ protected function findPlaces(Definition $definition, Marking $marking = null): /** * @internal */ - protected function findTransitions(Definition $definition): array + protected function findTransitions(Definition $definition, bool $withMetadata): array { $workflowMetadata = $definition->getMetadataStore(); @@ -113,9 +122,16 @@ protected function findTransitions(Definition $definition): array } $name = $workflowMetadata->getMetadata('label', $transition) ?? $transition->getName(); + $metadata = []; + if ($withMetadata) { + $metadata = $workflowMetadata->getTransitionMetadata($transition); + unset($metadata['label']); + } + $transitions[] = [ 'attributes' => $attributes, 'name' => $name, + 'metadata' => $metadata, ]; } @@ -125,7 +141,7 @@ protected function findTransitions(Definition $definition): array /** * @internal */ - protected function addPlaces(array $places): string + protected function addPlaces(array $places, bool $withMetadata): string { $code = ''; @@ -137,7 +153,15 @@ protected function addPlaces(array $places): string $placeName = $id; } - $code .= sprintf(" place_%s [label=\"%s\", shape=circle%s];\n", $this->dotize($id), $this->escape($placeName), $this->addAttributes($place['attributes'])); + if ($withMetadata) { + $escapedLabel = \sprintf('<%s%s>', $this->escape($placeName), $this->addMetadata($place['attributes']['metadata'])); + // Don't include metadata in default attributes used to format the place + unset($place['attributes']['metadata']); + } else { + $escapedLabel = \sprintf('"%s"', $this->escape($placeName)); + } + + $code .= \sprintf(" place_%s [label=%s, shape=circle%s];\n", $this->dotize($id), $escapedLabel, $this->addAttributes($place['attributes'])); } return $code; @@ -146,12 +170,18 @@ protected function addPlaces(array $places): string /** * @internal */ - protected function addTransitions(array $transitions): string + protected function addTransitions(array $transitions, bool $withMetadata): string { $code = ''; foreach ($transitions as $i => $place) { - $code .= sprintf(" transition_%s [label=\"%s\",%s];\n", $this->dotize($i), $this->escape($place['name']), $this->addAttributes($place['attributes'])); + if ($withMetadata) { + $escapedLabel = \sprintf('<%s%s>', $this->escape($place['name']), $this->addMetadata($place['metadata'])); + } else { + $escapedLabel = '"'.$this->escape($place['name']).'"'; + } + + $code .= \sprintf(" transition_%s [label=%s,%s];\n", $this->dotize($i), $escapedLabel, $this->addAttributes($place['attributes'])); } return $code; @@ -199,12 +229,12 @@ protected function addEdges(array $edges): string foreach ($edges as $edge) { if ('from' === $edge['direction']) { - $code .= sprintf(" place_%s -> transition_%s [style=\"solid\"];\n", + $code .= \sprintf(" place_%s -> transition_%s [style=\"solid\"];\n", $this->dotize($edge['from']), $this->dotize($edge['transition_number']) ); } else { - $code .= sprintf(" transition_%s -> place_%s [style=\"solid\"];\n", + $code .= \sprintf(" transition_%s -> place_%s [style=\"solid\"];\n", $this->dotize($edge['transition_number']), $this->dotize($edge['to']) ); @@ -217,10 +247,11 @@ protected function addEdges(array $edges): string /** * @internal */ - protected function startDot(array $options): string + protected function startDot(array $options, string $label): string { - return sprintf("digraph workflow {\n %s\n node [%s];\n edge [%s];\n\n", + return \sprintf("digraph workflow {\n %s%s\n node [%s];\n edge [%s];\n\n", $this->addOptions($options['graph']), + '""' !== $label && '<>' !== $label ? \sprintf(' label=%s', $label) : '', $this->addOptions($options['node']), $this->addOptions($options['edge']) ); @@ -245,30 +276,81 @@ protected function dotize(string $id): string /** * @internal */ - protected function escape($value): string + protected function escape(string|bool $value): string { return \is_bool($value) ? ($value ? '1' : '0') : addslashes($value); } + /** + * @internal + */ protected function addAttributes(array $attributes): string { $code = []; foreach ($attributes as $k => $v) { - $code[] = sprintf('%s="%s"', $k, $this->escape($v)); + $code[] = \sprintf('%s="%s"', $k, $this->escape($v)); } return $code ? ' '.implode(' ', $code) : ''; } + /** + * Handles the label of the graph depending on whether a label was set in CLI, + * if metadata should be included and if there are any. + * + * The produced label must be escaped. + * + * @internal + */ + protected function formatLabel(Definition $definition, bool $withMetadata, array $options): string + { + $currentLabel = $options['label'] ?? ''; + + if (!$withMetadata) { + // Only currentLabel to handle. If null, will be translated to empty string + return \sprintf('"%s"', $this->escape($currentLabel)); + } + $workflowMetadata = $definition->getMetadataStore()->getWorkflowMetadata(); + + if ('' === $currentLabel) { + // Only metadata to handle + return \sprintf('<%s>', $this->addMetadata($workflowMetadata, false)); + } + + // currentLabel and metadata to handle + return \sprintf('<%s%s>', $this->escape($currentLabel), $this->addMetadata($workflowMetadata)); + } + private function addOptions(array $options): string { $code = []; foreach ($options as $k => $v) { - $code[] = sprintf('%s="%s"', $k, $v); + $code[] = \sprintf('%s="%s"', $k, $v); } return implode(' ', $code); } + + /** + * @param bool $lineBreakFirstIfNotEmpty Whether to add a separator in the first place when metadata is not empty + */ + private function addMetadata(array $metadata, bool $lineBreakFirstIfNotEmpty = true): string + { + $code = []; + + $skipSeparator = !$lineBreakFirstIfNotEmpty; + + foreach ($metadata as $key => $value) { + if ($skipSeparator) { + $code[] = \sprintf('%s: %s', $this->escape($key), $this->escape($value)); + $skipSeparator = false; + } else { + $code[] = \sprintf('%s%s: %s', '
', $this->escape($key), $this->escape($value)); + } + } + + return $code ? implode('', $code) : ''; + } } diff --git a/Dumper/MermaidDumper.php b/Dumper/MermaidDumper.php new file mode 100644 index 0000000..bd7a6fa --- /dev/null +++ b/Dumper/MermaidDumper.php @@ -0,0 +1,253 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Dumper; + +use Symfony\Component\Workflow\Definition; +use Symfony\Component\Workflow\Exception\InvalidArgumentException; +use Symfony\Component\Workflow\Marking; + +class MermaidDumper implements DumperInterface +{ + public const DIRECTION_TOP_TO_BOTTOM = 'TB'; + public const DIRECTION_TOP_DOWN = 'TD'; + public const DIRECTION_BOTTOM_TO_TOP = 'BT'; + public const DIRECTION_RIGHT_TO_LEFT = 'RL'; + public const DIRECTION_LEFT_TO_RIGHT = 'LR'; + + private const VALID_DIRECTIONS = [ + self::DIRECTION_TOP_TO_BOTTOM, + self::DIRECTION_TOP_DOWN, + self::DIRECTION_BOTTOM_TO_TOP, + self::DIRECTION_RIGHT_TO_LEFT, + self::DIRECTION_LEFT_TO_RIGHT, + ]; + + public const TRANSITION_TYPE_STATEMACHINE = 'statemachine'; + public const TRANSITION_TYPE_WORKFLOW = 'workflow'; + + private const VALID_TRANSITION_TYPES = [ + self::TRANSITION_TYPE_STATEMACHINE, + self::TRANSITION_TYPE_WORKFLOW, + ]; + + /** + * Just tracking the transition id is in some cases inaccurate to + * get the link's number for styling purposes. + */ + private int $linkCount = 0; + + public function __construct( + private string $transitionType, + private string $direction = self::DIRECTION_LEFT_TO_RIGHT, + ) { + $this->validateDirection($direction); + $this->validateTransitionType($transitionType); + } + + public function dump(Definition $definition, ?Marking $marking = null, array $options = []): string + { + $this->linkCount = 0; + $placeNameMap = []; + $placeId = 0; + + $output = ['graph '.$this->direction]; + + $meta = $definition->getMetadataStore(); + + foreach ($definition->getPlaces() as $place) { + [$placeNodeName, $placeNode, $placeStyle] = $this->preparePlace( + $placeId, + $place, + $meta->getPlaceMetadata($place), + \in_array($place, $definition->getInitialPlaces(), true), + $marking?->has($place) ?? false + ); + + $output[] = $placeNode; + + if ('' !== $placeStyle) { + $output[] = $placeStyle; + } + + $placeNameMap[$place] = $placeNodeName; + + ++$placeId; + } + + foreach ($definition->getTransitions() as $transitionId => $transition) { + $transitionMeta = $meta->getTransitionMetadata($transition); + + $transitionLabel = $transition->getName(); + if (\array_key_exists('label', $transitionMeta)) { + $transitionLabel = $transitionMeta['label']; + } + + foreach ($transition->getFroms() as $from) { + $from = $placeNameMap[$from]; + + foreach ($transition->getTos() as $to) { + $to = $placeNameMap[$to]; + + if (self::TRANSITION_TYPE_STATEMACHINE === $this->transitionType) { + $transitionOutput = $this->styleStateMachineTransition($from, $to, $transitionLabel, $transitionMeta); + } else { + $transitionOutput = $this->styleWorkflowTransition($from, $to, $transitionId, $transitionLabel, $transitionMeta); + } + + foreach ($transitionOutput as $line) { + if (\in_array($line, $output)) { + // additional links must be decremented again to align the styling + if (0 < strpos($line, '-->')) { + --$this->linkCount; + } + + continue; + } + + $output[] = $line; + } + } + } + } + + return implode("\n", $output); + } + + private function preparePlace(int $placeId, string $placeName, array $meta, bool $isInitial, bool $hasMarking): array + { + $placeLabel = $placeName; + if (\array_key_exists('label', $meta)) { + $placeLabel = $meta['label']; + } + + $placeLabel = $this->escape($placeLabel); + + $labelShape = '((%s))'; + if ($isInitial) { + $labelShape = '([%s])'; + } + + $placeNodeName = 'place'.$placeId; + $placeNodeFormat = '%s'.$labelShape; + $placeNode = \sprintf($placeNodeFormat, $placeNodeName, $placeLabel); + + $placeStyle = $this->styleNode($meta, $placeNodeName, $hasMarking); + + return [$placeNodeName, $placeNode, $placeStyle]; + } + + private function styleNode(array $meta, string $nodeName, bool $hasMarking = false): string + { + $nodeStyles = []; + + if (\array_key_exists('bg_color', $meta)) { + $nodeStyles[] = \sprintf( + 'fill:%s', + $meta['bg_color'] + ); + } + + if ($hasMarking) { + $nodeStyles[] = 'stroke-width:4px'; + } + + if (0 === \count($nodeStyles)) { + return ''; + } + + return \sprintf('style %s %s', $nodeName, implode(',', $nodeStyles)); + } + + /** + * Replace double quotes with the mermaid escape syntax and + * ensure all other characters are properly escaped. + */ + private function escape(string $label): string + { + $label = str_replace('"', '#quot;', $label); + + return \sprintf('"%s"', $label); + } + + public function validateDirection(string $direction): void + { + if (!\in_array($direction, self::VALID_DIRECTIONS, true)) { + throw new InvalidArgumentException(\sprintf('Direction "%s" is not valid, valid directions are: "%s".', $direction, implode(', ', self::VALID_DIRECTIONS))); + } + } + + private function validateTransitionType(string $transitionType): void + { + if (!\in_array($transitionType, self::VALID_TRANSITION_TYPES, true)) { + throw new InvalidArgumentException(\sprintf('Transition type "%s" is not valid, valid types are: "%s".', $transitionType, implode(', ', self::VALID_TRANSITION_TYPES))); + } + } + + private function styleStateMachineTransition(string $from, string $to, string $transitionLabel, array $transitionMeta): array + { + $transitionOutput = [\sprintf('%s-->|%s|%s', $from, str_replace("\n", ' ', $this->escape($transitionLabel)), $to)]; + + $linkStyle = $this->styleLink($transitionMeta); + if ('' !== $linkStyle) { + $transitionOutput[] = $linkStyle; + } + + ++$this->linkCount; + + return $transitionOutput; + } + + private function styleWorkflowTransition(string $from, string $to, int $transitionId, string $transitionLabel, array $transitionMeta): array + { + $transitionOutput = []; + + $transitionLabel = $this->escape($transitionLabel); + $transitionNodeName = 'transition'.$transitionId; + + $transitionOutput[] = \sprintf('%s[%s]', $transitionNodeName, $transitionLabel); + + $transitionNodeStyle = $this->styleNode($transitionMeta, $transitionNodeName); + if ('' !== $transitionNodeStyle) { + $transitionOutput[] = $transitionNodeStyle; + } + + $connectionStyle = '%s-->%s'; + $transitionOutput[] = \sprintf($connectionStyle, $from, $transitionNodeName); + + $linkStyle = $this->styleLink($transitionMeta); + if ('' !== $linkStyle) { + $transitionOutput[] = $linkStyle; + } + + ++$this->linkCount; + + $transitionOutput[] = \sprintf($connectionStyle, $transitionNodeName, $to); + + $linkStyle = $this->styleLink($transitionMeta); + if ('' !== $linkStyle) { + $transitionOutput[] = $linkStyle; + } + + ++$this->linkCount; + + return $transitionOutput; + } + + private function styleLink(array $transitionMeta): string + { + if (\array_key_exists('color', $transitionMeta)) { + return \sprintf('linkStyle %d stroke:%s', $this->linkCount, $transitionMeta['color']); + } + + return ''; + } +} diff --git a/Dumper/PlantUmlDumper.php b/Dumper/PlantUmlDumper.php index 977ef69..9bd621a 100644 --- a/Dumper/PlantUmlDumper.php +++ b/Dumper/PlantUmlDumper.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Workflow\Dumper; -use InvalidArgumentException; use Symfony\Component\Workflow\Definition; use Symfony\Component\Workflow\Marking; use Symfony\Component\Workflow\Metadata\MetadataStoreInterface; @@ -31,10 +30,10 @@ class PlantUmlDumper implements DumperInterface private const INITIAL = '<>'; private const MARKED = '<>'; - const STATEMACHINE_TRANSITION = 'arrow'; - const WORKFLOW_TRANSITION = 'square'; - const TRANSITION_TYPES = [self::STATEMACHINE_TRANSITION, self::WORKFLOW_TRANSITION]; - const DEFAULT_OPTIONS = [ + public const STATEMACHINE_TRANSITION = 'arrow'; + public const WORKFLOW_TRANSITION = 'square'; + public const TRANSITION_TYPES = [self::STATEMACHINE_TRANSITION, self::WORKFLOW_TRANSITION]; + public const DEFAULT_OPTIONS = [ 'skinparams' => [ 'titleBorderRoundCorner' => 15, 'titleBorderThickness' => 2, @@ -52,17 +51,15 @@ class PlantUmlDumper implements DumperInterface ], ]; - private $transitionType = self::STATEMACHINE_TRANSITION; - - public function __construct(string $transitionType = null) - { + public function __construct( + private string $transitionType, + ) { if (!\in_array($transitionType, self::TRANSITION_TYPES, true)) { - throw new InvalidArgumentException("Transition type '$transitionType' does not exist."); + throw new \InvalidArgumentException("Transition type '$transitionType' does not exist."); } - $this->transitionType = $transitionType; } - public function dump(Definition $definition, Marking $marking = null, array $options = []): string + public function dump(Definition $definition, ?Marking $marking = null, array $options = []): string { $options = array_replace_recursive(self::DEFAULT_OPTIONS, $options); @@ -103,8 +100,8 @@ public function dump(Definition $definition, Marking $marking = null, array $opt } $lines = [ - "$fromEscaped -${transitionColor}-> ${transitionEscaped}${transitionLabel}", - "$transitionEscaped -${transitionColor}-> ${toEscaped}${transitionLabel}", + "{$fromEscaped} -{$transitionColor}-> {$transitionEscaped}{$transitionLabel}", + "{$transitionEscaped} -{$transitionColor}-> {$toEscaped}{$transitionLabel}", ]; foreach ($lines as $line) { if (!\in_array($line, $code)) { @@ -112,13 +109,13 @@ public function dump(Definition $definition, Marking $marking = null, array $opt } } } else { - $code[] = "$fromEscaped -${transitionColor}-> $toEscaped: $transitionEscapedWithStyle"; + $code[] = "{$fromEscaped} -{$transitionColor}-> {$toEscaped}: {$transitionEscapedWithStyle}"; } } } } - return $this->startPuml($options).$this->getLines($code).$this->endPuml($options); + return $this->startPuml().$this->getLines($code).$this->endPuml(); } private function isWorkflowTransitionType(): bool @@ -126,22 +123,19 @@ private function isWorkflowTransitionType(): bool return self::WORKFLOW_TRANSITION === $this->transitionType; } - private function startPuml(array $options): string + private function startPuml(): string { - $start = '@startuml'.PHP_EOL; - $start .= 'allow_mixing'.PHP_EOL; - - return $start; + return '@startuml'.\PHP_EOL.'allow_mixing'.\PHP_EOL; } - private function endPuml(array $options): string + private function endPuml(): string { - return PHP_EOL.'@enduml'; + return \PHP_EOL.'@enduml'; } private function getLines(array $code): string { - return implode(PHP_EOL, $code); + return implode(\PHP_EOL, $code); } private function initialize(array $options, Definition $definition): array @@ -192,15 +186,15 @@ private function escape(string $string): string return '"'.str_replace('"', '', $string).'"'; } - private function getState(string $place, Definition $definition, Marking $marking = null): string + private function getState(string $place, Definition $definition, ?Marking $marking = null): string { $workflowMetadata = $definition->getMetadataStore(); - $placeEscaped = $this->escape($place); + $placeEscaped = str_replace("\n", ' ', $this->escape($place)); $output = "state $placeEscaped". (\in_array($place, $definition->getInitialPlaces(), true) ? ' '.self::INITIAL : ''). - ($marking && $marking->has($place) ? ' '.self::MARKED : ''); + ($marking?->has($place) ? ' '.self::MARKED : ''); $backgroundColor = $workflowMetadata->getMetadata('bg_color', $place); if (null !== $backgroundColor) { @@ -209,9 +203,9 @@ private function getState(string $place, Definition $definition, Marking $markin $description = $workflowMetadata->getMetadata('description', $place); if (null !== $description) { - $output .= ' as '.$place. - PHP_EOL. - $place.' : '.$description; + foreach (array_filter(explode("\n", $description)) as $line) { + $output .= "\n".$placeEscaped.' : '.$line; + } } return $output; @@ -220,11 +214,18 @@ private function getState(string $place, Definition $definition, Marking $markin private function getTransitionEscapedWithStyle(MetadataStoreInterface $workflowMetadata, Transition $transition, string $to): string { $to = $workflowMetadata->getMetadata('label', $transition) ?? $to; + // Change new lines symbols to actual '\n' string, + // PUML will render them as new lines + $to = str_replace("\n", '\n', $to); $color = $workflowMetadata->getMetadata('color', $transition) ?? null; if (null !== $color) { - $to = sprintf( + // Close and open before and after every '\n' string, + // so that the style is applied properly on every line + $to = str_replace('\n', \sprintf('\n', $color), $to); + + $to = \sprintf( '%2$s', $color, $to @@ -237,11 +238,11 @@ private function getTransitionEscapedWithStyle(MetadataStoreInterface $workflowM private function getTransitionColor(string $color): string { // PUML format requires that color in transition have to be prefixed with “#”. - if ('#' !== substr($color, 0, 1)) { + if (!str_starts_with($color, '#')) { $color = '#'.$color; } - return sprintf('[%s]', $color); + return \sprintf('[%s]', $color); } private function getColorId(string $color): string diff --git a/Dumper/StateMachineGraphvizDumper.php b/Dumper/StateMachineGraphvizDumper.php index 4bd818d..7bd9d73 100644 --- a/Dumper/StateMachineGraphvizDumper.php +++ b/Dumper/StateMachineGraphvizDumper.php @@ -17,8 +17,6 @@ class StateMachineGraphvizDumper extends GraphvizDumper { /** - * {@inheritdoc} - * * Dumps the workflow as a graphviz graph. * * Available options: @@ -27,18 +25,21 @@ class StateMachineGraphvizDumper extends GraphvizDumper * * node: The default options for nodes (places) * * edge: The default options for edges */ - public function dump(Definition $definition, Marking $marking = null, array $options = []) + public function dump(Definition $definition, ?Marking $marking = null, array $options = []): string { - $places = $this->findPlaces($definition, $marking); + $withMetadata = $options['with-metadata'] ?? false; + + $places = $this->findPlaces($definition, $withMetadata, $marking); $edges = $this->findEdges($definition); $options = array_replace_recursive(self::$defaultOptions, $options); - return $this->startDot($options) - .$this->addPlaces($places) + $label = $this->formatLabel($definition, $withMetadata, $options); + + return $this->startDot($options, $label) + .$this->addPlaces($places, $withMetadata) .$this->addEdges($edges) - .$this->endDot() - ; + .$this->endDot(); } /** @@ -88,7 +89,7 @@ protected function addEdges(array $edges): string foreach ($edges as $id => $edges) { foreach ($edges as $edge) { - $code .= sprintf( + $code .= \sprintf( " place_%s -> place_%s [label=\"%s\" style=\"%s\"%s];\n", $this->dotize($id), $this->dotize($edge['to']), diff --git a/Event/AnnounceEvent.php b/Event/AnnounceEvent.php index 7d3d740..0bff3dc 100644 --- a/Event/AnnounceEvent.php +++ b/Event/AnnounceEvent.php @@ -11,6 +11,21 @@ namespace Symfony\Component\Workflow\Event; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Transition; +use Symfony\Component\Workflow\WorkflowInterface; + final class AnnounceEvent extends Event { + use EventNameTrait { + getNameForTransition as public getName; + } + use HasContextTrait; + + public function __construct(object $subject, Marking $marking, ?Transition $transition = null, ?WorkflowInterface $workflow = null, array $context = []) + { + parent::__construct($subject, $marking, $transition, $workflow); + + $this->context = $context; + } } diff --git a/Event/CompletedEvent.php b/Event/CompletedEvent.php index 883390e..885826f 100644 --- a/Event/CompletedEvent.php +++ b/Event/CompletedEvent.php @@ -11,6 +11,21 @@ namespace Symfony\Component\Workflow\Event; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Transition; +use Symfony\Component\Workflow\WorkflowInterface; + final class CompletedEvent extends Event { + use EventNameTrait { + getNameForTransition as public getName; + } + use HasContextTrait; + + public function __construct(object $subject, Marking $marking, ?Transition $transition = null, ?WorkflowInterface $workflow = null, array $context = []) + { + parent::__construct($subject, $marking, $transition, $workflow); + + $this->context = $context; + } } diff --git a/Event/EnterEvent.php b/Event/EnterEvent.php index 3296f29..46e1041 100644 --- a/Event/EnterEvent.php +++ b/Event/EnterEvent.php @@ -11,6 +11,21 @@ namespace Symfony\Component\Workflow\Event; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Transition; +use Symfony\Component\Workflow\WorkflowInterface; + final class EnterEvent extends Event { + use EventNameTrait { + getNameForPlace as public getName; + } + use HasContextTrait; + + public function __construct(object $subject, Marking $marking, ?Transition $transition = null, ?WorkflowInterface $workflow = null, array $context = []) + { + parent::__construct($subject, $marking, $transition, $workflow); + + $this->context = $context; + } } diff --git a/Event/EnteredEvent.php b/Event/EnteredEvent.php index ea3624b..a71610d 100644 --- a/Event/EnteredEvent.php +++ b/Event/EnteredEvent.php @@ -11,6 +11,21 @@ namespace Symfony\Component\Workflow\Event; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Transition; +use Symfony\Component\Workflow\WorkflowInterface; + final class EnteredEvent extends Event { + use EventNameTrait { + getNameForPlace as public getName; + } + use HasContextTrait; + + public function __construct(object $subject, Marking $marking, ?Transition $transition = null, ?WorkflowInterface $workflow = null, array $context = []) + { + parent::__construct($subject, $marking, $transition, $workflow); + + $this->context = $context; + } } diff --git a/Event/Event.php b/Event/Event.php index e1f448a..c13818b 100644 --- a/Event/Event.php +++ b/Event/Event.php @@ -23,53 +23,46 @@ */ class Event extends BaseEvent { - protected $context; - private $subject; - private $marking; - private $transition; - private $workflow; - - public function __construct(object $subject, Marking $marking, Transition $transition = null, WorkflowInterface $workflow = null, array $context = []) - { - $this->subject = $subject; - $this->marking = $marking; - $this->transition = $transition; - $this->workflow = $workflow; - $this->context = $context; + public function __construct( + private object $subject, + private Marking $marking, + private ?Transition $transition = null, + private ?WorkflowInterface $workflow = null, + ) { } - public function getMarking() + public function getMarking(): Marking { return $this->marking; } - public function getSubject() + public function getSubject(): object { return $this->subject; } - public function getTransition() + public function getTransition(): ?Transition { return $this->transition; } + /** + * @deprecated since Symfony 7.3, inject the workflow in the constructor where you need it + */ public function getWorkflow(): WorkflowInterface { + trigger_deprecation('symfony/workflow', '7.3', 'The "%s()" method is deprecated, inject the workflow in the constructor where you need it.', __METHOD__); + return $this->workflow; } - public function getWorkflowName() + public function getWorkflowName(): string { return $this->workflow->getName(); } - public function getMetadata(string $key, $subject) + public function getMetadata(string $key, string|Transition|null $subject): mixed { return $this->workflow->getMetadataStore()->getMetadata($key, $subject); } - - public function getContext(): array - { - return $this->context; - } } diff --git a/Event/EventNameTrait.php b/Event/EventNameTrait.php new file mode 100644 index 0000000..1f77b37 --- /dev/null +++ b/Event/EventNameTrait.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Event; + +use Symfony\Component\Workflow\Exception\InvalidArgumentException; + +/** + * @author Nicolas Rigaud + * + * @internal + */ +trait EventNameTrait +{ + /** + * Gets the event name for workflow and transition. + * + * @throws InvalidArgumentException If $transitionName is provided without $workflowName + */ + private static function getNameForTransition(?string $workflowName, ?string $transitionName): string + { + return self::computeName($workflowName, $transitionName); + } + + /** + * Gets the event name for workflow and place. + * + * @throws InvalidArgumentException If $placeName is provided without $workflowName + */ + private static function getNameForPlace(?string $workflowName, ?string $placeName): string + { + return self::computeName($workflowName, $placeName); + } + + private static function computeName(?string $workflowName, ?string $transitionOrPlaceName): string + { + $eventName = strtolower(basename(str_replace('\\', '/', static::class), 'Event')); + + if (null === $workflowName) { + if (null !== $transitionOrPlaceName) { + throw new \InvalidArgumentException('Missing workflow name.'); + } + + return \sprintf('workflow.%s', $eventName); + } + + if (null === $transitionOrPlaceName) { + return \sprintf('workflow.%s.%s', $workflowName, $eventName); + } + + return \sprintf('workflow.%s.%s.%s', $workflowName, $eventName, $transitionOrPlaceName); + } +} diff --git a/Event/GuardEvent.php b/Event/GuardEvent.php index 317fe89..fbbcf22 100644 --- a/Event/GuardEvent.php +++ b/Event/GuardEvent.php @@ -23,24 +23,30 @@ */ final class GuardEvent extends Event { - private $transitionBlockerList; + use EventNameTrait { + getNameForTransition as public getName; + } + + private TransitionBlockerList $transitionBlockerList; - /** - * {@inheritdoc} - */ - public function __construct(object $subject, Marking $marking, Transition $transition, WorkflowInterface $workflow = null) + public function __construct(object $subject, Marking $marking, Transition $transition, ?WorkflowInterface $workflow = null) { parent::__construct($subject, $marking, $transition, $workflow); $this->transitionBlockerList = new TransitionBlockerList(); } + public function getTransition(): Transition + { + return parent::getTransition(); + } + public function isBlocked(): bool { return !$this->transitionBlockerList->isEmpty(); } - public function setBlocked(bool $blocked, string $message = null): void + public function setBlocked(bool $blocked, ?string $message = null): void { if (!$blocked) { $this->transitionBlockerList->clear(); diff --git a/Event/HasContextTrait.php b/Event/HasContextTrait.php new file mode 100644 index 0000000..4fc3d87 --- /dev/null +++ b/Event/HasContextTrait.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Event; + +/** + * @author Fabien Potencier + * @author Grégoire Pineau + * @author Hugo Hamon + * + * @internal + */ +trait HasContextTrait +{ + private array $context = []; + + public function getContext(): array + { + return $this->context; + } +} diff --git a/Event/LeaveEvent.php b/Event/LeaveEvent.php index d3d48cb..78fd1b6 100644 --- a/Event/LeaveEvent.php +++ b/Event/LeaveEvent.php @@ -11,6 +11,21 @@ namespace Symfony\Component\Workflow\Event; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Transition; +use Symfony\Component\Workflow\WorkflowInterface; + final class LeaveEvent extends Event { + use EventNameTrait { + getNameForPlace as public getName; + } + use HasContextTrait; + + public function __construct(object $subject, Marking $marking, ?Transition $transition = null, ?WorkflowInterface $workflow = null, array $context = []) + { + parent::__construct($subject, $marking, $transition, $workflow); + + $this->context = $context; + } } diff --git a/Event/TransitionEvent.php b/Event/TransitionEvent.php index 4710f90..a7a3dd0 100644 --- a/Event/TransitionEvent.php +++ b/Event/TransitionEvent.php @@ -11,8 +11,24 @@ namespace Symfony\Component\Workflow\Event; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Transition; +use Symfony\Component\Workflow\WorkflowInterface; + final class TransitionEvent extends Event { + use EventNameTrait { + getNameForTransition as public getName; + } + use HasContextTrait; + + public function __construct(object $subject, Marking $marking, ?Transition $transition = null, ?WorkflowInterface $workflow = null, array $context = []) + { + parent::__construct($subject, $marking, $transition, $workflow); + + $this->context = $context; + } + public function setContext(array $context): void { $this->context = $context; diff --git a/EventListener/AuditTrailListener.php b/EventListener/AuditTrailListener.php index 95a9590..fe7ccdf 100644 --- a/EventListener/AuditTrailListener.php +++ b/EventListener/AuditTrailListener.php @@ -20,33 +20,31 @@ */ class AuditTrailListener implements EventSubscriberInterface { - private $logger; - - public function __construct(LoggerInterface $logger) - { - $this->logger = $logger; + public function __construct( + private LoggerInterface $logger, + ) { } - public function onLeave(Event $event) + public function onLeave(Event $event): void { foreach ($event->getTransition()->getFroms() as $place) { - $this->logger->info(sprintf('Leaving "%s" for subject of class "%s" in workflow "%s".', $place, \get_class($event->getSubject()), $event->getWorkflowName())); + $this->logger->info(\sprintf('Leaving "%s" for subject of class "%s" in workflow "%s".', $place, $event->getSubject()::class, $event->getWorkflowName())); } } - public function onTransition(Event $event) + public function onTransition(Event $event): void { - $this->logger->info(sprintf('Transition "%s" for subject of class "%s" in workflow "%s".', $event->getTransition()->getName(), \get_class($event->getSubject()), $event->getWorkflowName())); + $this->logger->info(\sprintf('Transition "%s" for subject of class "%s" in workflow "%s".', $event->getTransition()->getName(), $event->getSubject()::class, $event->getWorkflowName())); } - public function onEnter(Event $event) + public function onEnter(Event $event): void { foreach ($event->getTransition()->getTos() as $place) { - $this->logger->info(sprintf('Entering "%s" for subject of class "%s" in workflow "%s".', $place, \get_class($event->getSubject()), $event->getWorkflowName())); + $this->logger->info(\sprintf('Entering "%s" for subject of class "%s" in workflow "%s".', $place, $event->getSubject()::class, $event->getWorkflowName())); } } - public static function getSubscribedEvents() + public static function getSubscribedEvents(): array { return [ 'workflow.leave' => ['onLeave'], diff --git a/EventListener/ExpressionLanguage.php b/EventListener/ExpressionLanguage.php index 6fb4307..257f885 100644 --- a/EventListener/ExpressionLanguage.php +++ b/EventListener/ExpressionLanguage.php @@ -22,21 +22,15 @@ */ class ExpressionLanguage extends BaseExpressionLanguage { - protected function registerFunctions() + protected function registerFunctions(): void { parent::registerFunctions(); - $this->register('is_granted', function ($attributes, $object = 'null') { - return sprintf('$auth_checker->isGranted(%s, %s)', $attributes, $object); - }, function (array $variables, $attributes, $object = null) { - return $variables['auth_checker']->isGranted($attributes, $object); - }); + $this->register('is_granted', fn ($attributes, $object = 'null') => \sprintf('$auth_checker->isGranted(%s, %s)', $attributes, $object), fn (array $variables, $attributes, $object = null) => $variables['auth_checker']->isGranted($attributes, $object)); - $this->register('is_valid', function ($object = 'null', $groups = 'null') { - return sprintf('0 === count($validator->validate(%s, null, %s))', $object, $groups); - }, function (array $variables, $object = null, $groups = null) { + $this->register('is_valid', fn ($object = 'null', $groups = 'null') => \sprintf('0 === count($validator->validate(%s, null, %s))', $object, $groups), function (array $variables, $object = null, $groups = null) { if (!$variables['validator'] instanceof ValidatorInterface) { - throw new RuntimeException('"is_valid" cannot be used as the Validator component is not installed.'); + throw new RuntimeException('"is_valid" cannot be used as the Validator component is not installed. Try running "composer require symfony/validator".'); } $errors = $variables['validator']->validate($object, null, $groups); diff --git a/EventListener/GuardExpression.php b/EventListener/GuardExpression.php index c7ac9ce..deb148d 100644 --- a/EventListener/GuardExpression.php +++ b/EventListener/GuardExpression.php @@ -15,21 +15,18 @@ class GuardExpression { - private $transition; - private $expression; - - public function __construct(Transition $transition, string $expression) - { - $this->transition = $transition; - $this->expression = $expression; + public function __construct( + private Transition $transition, + private string $expression, + ) { } - public function getTransition() + public function getTransition(): Transition { return $this->transition; } - public function getExpression() + public function getExpression(): string { return $this->expression; } diff --git a/EventListener/GuardListener.php b/EventListener/GuardListener.php index 9329d40..23cdd7a 100644 --- a/EventListener/GuardListener.php +++ b/EventListener/GuardListener.php @@ -17,7 +17,6 @@ use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Workflow\Event\GuardEvent; -use Symfony\Component\Workflow\Exception\InvalidTokenConfigurationException; use Symfony\Component\Workflow\TransitionBlocker; /** @@ -25,26 +24,18 @@ */ class GuardListener { - private $configuration; - private $expressionLanguage; - private $tokenStorage; - private $authorizationChecker; - private $trustResolver; - private $roleHierarchy; - private $validator; - - public function __construct(array $configuration, ExpressionLanguage $expressionLanguage, TokenStorageInterface $tokenStorage, AuthorizationCheckerInterface $authorizationChecker, AuthenticationTrustResolverInterface $trustResolver, RoleHierarchyInterface $roleHierarchy = null, ValidatorInterface $validator = null) - { - $this->configuration = $configuration; - $this->expressionLanguage = $expressionLanguage; - $this->tokenStorage = $tokenStorage; - $this->authorizationChecker = $authorizationChecker; - $this->trustResolver = $trustResolver; - $this->roleHierarchy = $roleHierarchy; - $this->validator = $validator; + public function __construct( + private array $configuration, + private ExpressionLanguage $expressionLanguage, + private TokenStorageInterface $tokenStorage, + private AuthorizationCheckerInterface $authorizationChecker, + private AuthenticationTrustResolverInterface $trustResolver, + private ?RoleHierarchyInterface $roleHierarchy = null, + private ?ValidatorInterface $validator = null, + ) { } - public function onTransition(GuardEvent $event, string $eventName) + public function onTransition(GuardEvent $event, string $eventName): void { if (!isset($this->configuration[$eventName])) { return; @@ -63,7 +54,7 @@ public function onTransition(GuardEvent $event, string $eventName) } } - private function validateGuardExpression(GuardEvent $event, string $expression) + private function validateGuardExpression(GuardEvent $event, string $expression): void { if (!$this->expressionLanguage->evaluate($expression, $this->getVariables($event))) { $blocker = TransitionBlocker::createBlockedByExpressionGuardListener($expression); @@ -76,15 +67,8 @@ private function getVariables(GuardEvent $event): array { $token = $this->tokenStorage->getToken(); - if (null === $token) { - throw new InvalidTokenConfigurationException(sprintf('There are no tokens available for workflow "%s".', $event->getWorkflowName())); - } - $variables = [ - 'token' => $token, - 'user' => $token->getUser(), 'subject' => $event->getSubject(), - 'role_names' => $this->roleHierarchy->getReachableRoleNames($token->getRoleNames()), // needed for the is_granted expression function 'auth_checker' => $this->authorizationChecker, // needed for the is_* expression function @@ -93,6 +77,18 @@ private function getVariables(GuardEvent $event): array 'validator' => $this->validator, ]; - return $variables; + if (null === $token) { + return $variables + [ + 'token' => null, + 'user' => null, + 'role_names' => [], + ]; + } + + return $variables + [ + 'token' => $token, + 'user' => $token->getUser(), + 'role_names' => $this->roleHierarchy->getReachableRoleNames($token->getRoleNames()), + ]; } } diff --git a/Exception/InvalidTokenConfigurationException.php b/Exception/InvalidTokenConfigurationException.php deleted file mode 100644 index a70fd4c..0000000 --- a/Exception/InvalidTokenConfigurationException.php +++ /dev/null @@ -1,21 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Workflow\Exception; - -/** - * Thrown by GuardListener when there is no token set, but guards are placed on a transition. - * - * @author Matt Johnson - */ -class InvalidTokenConfigurationException extends LogicException -{ -} diff --git a/Exception/NotEnabledTransitionException.php b/Exception/NotEnabledTransitionException.php index 1771234..26d1c8d 100644 --- a/Exception/NotEnabledTransitionException.php +++ b/Exception/NotEnabledTransitionException.php @@ -15,19 +15,20 @@ use Symfony\Component\Workflow\WorkflowInterface; /** - * Thrown by Workflow when a not enabled transition is applied on a subject. + * Thrown when a transition cannot be applied on a subject. * * @author Grégoire Pineau */ class NotEnabledTransitionException extends TransitionException { - private $transitionBlockerList; - - public function __construct(object $subject, string $transitionName, WorkflowInterface $workflow, TransitionBlockerList $transitionBlockerList, array $context = []) - { - parent::__construct($subject, $transitionName, $workflow, sprintf('Transition "%s" is not enabled for workflow "%s".', $transitionName, $workflow->getName()), $context); - - $this->transitionBlockerList = $transitionBlockerList; + public function __construct( + object $subject, + string $transitionName, + WorkflowInterface $workflow, + private TransitionBlockerList $transitionBlockerList, + array $context = [], + ) { + parent::__construct($subject, $transitionName, $workflow, \sprintf('Cannot apply transition "%s" on workflow "%s".', $transitionName, $workflow->getName()), $context); } public function getTransitionBlockerList(): TransitionBlockerList diff --git a/Exception/TransitionException.php b/Exception/TransitionException.php index 5e35725..e5c3846 100644 --- a/Exception/TransitionException.php +++ b/Exception/TransitionException.php @@ -19,22 +19,17 @@ */ class TransitionException extends LogicException { - private $subject; - private $transitionName; - private $workflow; - private $context; - - public function __construct(object $subject, string $transitionName, WorkflowInterface $workflow, string $message, array $context = []) - { + public function __construct( + private object $subject, + private string $transitionName, + private WorkflowInterface $workflow, + string $message, + private array $context = [], + ) { parent::__construct($message); - - $this->subject = $subject; - $this->transitionName = $transitionName; - $this->workflow = $workflow; - $this->context = $context; } - public function getSubject() + public function getSubject(): object { return $this->subject; } diff --git a/Exception/UndefinedTransitionException.php b/Exception/UndefinedTransitionException.php index 75d3848..5a8ecf8 100644 --- a/Exception/UndefinedTransitionException.php +++ b/Exception/UndefinedTransitionException.php @@ -22,6 +22,6 @@ class UndefinedTransitionException extends TransitionException { public function __construct(object $subject, string $transitionName, WorkflowInterface $workflow, array $context = []) { - parent::__construct($subject, $transitionName, $workflow, sprintf('Transition "%s" is not defined for workflow "%s".', $transitionName, $workflow->getName()), $context); + parent::__construct($subject, $transitionName, $workflow, \sprintf('Transition "%s" is not defined for workflow "%s".', $transitionName, $workflow->getName()), $context); } } diff --git a/LICENSE b/LICENSE index 684fbf9..29f72d5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2014-2020 Fabien Potencier +Copyright (c) 2014-present Fabien Potencier Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Marking.php b/Marking.php index 974a040..c3629a2 100644 --- a/Marking.php +++ b/Marking.php @@ -18,35 +18,89 @@ */ class Marking { - private $places = []; + private array $places = []; + private ?array $context = null; /** - * @param int[] $representation Keys are the place name and values should be 1 + * @param int[] $representation Keys are the place name and values should be superior or equals to 1 */ public function __construct(array $representation = []) { foreach ($representation as $place => $nbToken) { - $this->mark($place); + $this->mark($place, $nbToken); } } - public function mark(string $place) + /** + * @param int $nbToken + * + * @psalm-param int<1, max> $nbToken + */ + public function mark(string $place /* , int $nbToken = 1 */): void { - $this->places[$place] = 1; + $nbToken = 1 < \func_num_args() ? func_get_arg(1) : 1; + + if ($nbToken < 1) { + throw new \InvalidArgumentException(\sprintf('The number of tokens must be greater than 0, "%s" given.', $nbToken)); + } + + $this->places[$place] ??= 0; + $this->places[$place] += $nbToken; } - public function unmark(string $place) + /** + * @param int $nbToken + * + * @psalm-param int<1, max> $nbToken + */ + public function unmark(string $place /* , int $nbToken = 1 */): void { - unset($this->places[$place]); + $nbToken = 1 < \func_num_args() ? func_get_arg(1) : 1; + + if ($nbToken < 1) { + throw new \InvalidArgumentException(\sprintf('The number of tokens must be greater than 0, "%s" given.', $nbToken)); + } + + if (!$this->has($place)) { + throw new \InvalidArgumentException(\sprintf('The place "%s" is not marked.', $place)); + } + + $tokenCount = $this->places[$place] - $nbToken; + + if (0 > $tokenCount) { + throw new \InvalidArgumentException(\sprintf('The place "%s" could not contain a negative token number: "%s" (initial) - "%s" (nbToken) = "%s".', $place, $this->places[$place], $nbToken, $tokenCount)); + } + + if (0 === $tokenCount) { + unset($this->places[$place]); + } else { + $this->places[$place] = $tokenCount; + } } - public function has(string $place) + public function has(string $place): bool { return isset($this->places[$place]); } - public function getPlaces() + public function getPlaces(): array { return $this->places; } + + /** + * @internal + */ + public function setContext(array $context): void + { + $this->context = $context; + } + + /** + * Returns the context after the subject has transitioned. + */ + public function getContext(): ?array + { + return $this->context; + } } diff --git a/MarkingStore/MarkingStoreInterface.php b/MarkingStore/MarkingStoreInterface.php index 2d8d6ad..43b34f5 100644 --- a/MarkingStore/MarkingStoreInterface.php +++ b/MarkingStore/MarkingStoreInterface.php @@ -26,13 +26,11 @@ interface MarkingStoreInterface { /** * Gets a Marking from a subject. - * - * @return Marking The marking */ - public function getMarking(object $subject); + public function getMarking(object $subject): Marking; /** * Sets a Marking to a subject. */ - public function setMarking(object $subject, Marking $marking, array $context = []); + public function setMarking(object $subject, Marking $marking, array $context = []): void; } diff --git a/MarkingStore/MethodMarkingStore.php b/MarkingStore/MethodMarkingStore.php index ff6dde4..a2844b7 100644 --- a/MarkingStore/MethodMarkingStore.php +++ b/MarkingStore/MethodMarkingStore.php @@ -15,62 +15,64 @@ use Symfony\Component\Workflow\Marking; /** - * MethodMarkingStore stores the marking with a subject's method. + * MethodMarkingStore stores the marking with a subject's public method + * or public property. * - * This store deals with a "single state" or "multiple state" Marking. + * This store deals with a "single state" or "multiple state" marking. * - * "single state" Marking means a subject can be in one and only one state at - * the same time. Use it with state machine. + * "single state" marking means a subject can be in one and only one state at + * the same time. Use it with state machine. It uses a string to store the + * marking. * - * "multiple state" Marking means a subject can be in many states at the same - * time. Use it with workflow. + * "multiple state" marking means a subject can be in many states at the same + * time. Use it with workflow. It uses an array of strings to store the marking. * * @author Grégoire Pineau */ final class MethodMarkingStore implements MarkingStoreInterface { - private $singleState; - private $property; + /** @var array */ + private array $getters = []; + /** @var array */ + private array $setters = []; /** - * @param string $property Used to determine methods to call - * The `getMarking` method will use `$subject->getProperty()` - * The `setMarking` method will use `$subject->setProperty(string|array $places, array $context = array())` + * @param string $property Used to determine methods or property to call + * The `getMarking` method will use `$subject->getProperty()` or `$subject->property` + * The `setMarking` method will use `$subject->setProperty(string|array $places, array $context = [])` or `$subject->property = string|array $places` */ - public function __construct(bool $singleState = false, string $property = 'marking') - { - $this->singleState = $singleState; - $this->property = $property; + public function __construct( + private bool $singleState = false, + private string $property = 'marking', + ) { } - /** - * {@inheritdoc} - */ public function getMarking(object $subject): Marking { - $method = 'get'.ucfirst($this->property); - - if (!method_exists($subject, $method)) { - throw new LogicException(sprintf('The method "%s::%s()" does not exist.', get_debug_type($subject), $method)); + $marking = null; + try { + $marking = ($this->getGetter($subject))(); + } catch (\Error $e) { + $unInitializedPropertyMessage = \sprintf('Typed property %s::$%s must not be accessed before initialization', get_debug_type($subject), $this->property); + if ($e->getMessage() !== $unInitializedPropertyMessage) { + throw $e; + } } - $marking = $subject->{$method}(); - if (null === $marking) { return new Marking(); } if ($this->singleState) { $marking = [(string) $marking => 1]; + } elseif (!\is_array($marking)) { + throw new LogicException(\sprintf('The marking stored in "%s::$%s" is not an array and the Workflow\'s Marking store is instantiated with $singleState=false.', get_debug_type($subject), $this->property)); } return new Marking($marking); } - /** - * {@inheritdoc} - */ - public function setMarking(object $subject, Marking $marking, array $context = []) + public function setMarking(object $subject, Marking $marking, array $context = []): void { $marking = $marking->getPlaces(); @@ -78,12 +80,53 @@ public function setMarking(object $subject, Marking $marking, array $context = [ $marking = key($marking); } - $method = 'set'.ucfirst($this->property); + ($this->getSetter($subject))($marking, $context); + } + + private function getGetter(object $subject): callable + { + $property = $this->property; + $method = 'get'.ucfirst($property); + + return match ($this->getters[$subject::class] ??= self::getType($subject, $property, $method)) { + MarkingStoreMethod::METHOD => $subject->{$method}(...), + MarkingStoreMethod::PROPERTY => static fn () => $subject->{$property}, + }; + } + + private function getSetter(object $subject): callable + { + $property = $this->property; + $method = 'set'.ucfirst($property); + + return match ($this->setters[$subject::class] ??= self::getType($subject, $property, $method)) { + MarkingStoreMethod::METHOD => $subject->{$method}(...), + MarkingStoreMethod::PROPERTY => static fn ($marking) => $subject->{$property} = $marking, + }; + } + + private static function getType(object $subject, string $property, string $method): MarkingStoreMethod + { + if (method_exists($subject, $method) && (new \ReflectionMethod($subject, $method))->isPublic()) { + return MarkingStoreMethod::METHOD; + } - if (!method_exists($subject, $method)) { - throw new LogicException(sprintf('The method "%s::%s()" does not exist.', get_debug_type($subject), $method)); + try { + if ((new \ReflectionProperty($subject, $property))->isPublic()) { + return MarkingStoreMethod::PROPERTY; + } + } catch (\ReflectionException) { } - $subject->{$method}($marking, $context); + throw new LogicException(\sprintf('Cannot store marking: class "%s" should have either a public method named "%s()" or a public property named "$%s"; none found.', get_debug_type($subject), $method, $property)); } } + +/** + * @internal + */ +enum MarkingStoreMethod +{ + case METHOD; + case PROPERTY; +} diff --git a/Metadata/GetMetadataTrait.php b/Metadata/GetMetadataTrait.php index 04cd52c..83b57f7 100644 --- a/Metadata/GetMetadataTrait.php +++ b/Metadata/GetMetadataTrait.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Workflow\Metadata; -use Symfony\Component\Workflow\Exception\InvalidArgumentException; use Symfony\Component\Workflow\Transition; /** @@ -19,30 +18,14 @@ */ trait GetMetadataTrait { - public function getMetadata(string $key, $subject = null) + public function getMetadata(string $key, string|Transition|null $subject = null): mixed { if (null === $subject) { return $this->getWorkflowMetadata()[$key] ?? null; } - if (\is_string($subject)) { - $metadataBag = $this->getPlaceMetadata($subject); - if (!$metadataBag) { - return null; - } + $metadataBag = \is_string($subject) ? $this->getPlaceMetadata($subject) : $this->getTransitionMetadata($subject); - return $metadataBag[$key] ?? null; - } - - if ($subject instanceof Transition) { - $metadataBag = $this->getTransitionMetadata($subject); - if (!$metadataBag) { - return null; - } - - return $metadataBag[$key] ?? null; - } - - throw new InvalidArgumentException(sprintf('Could not find a MetadataBag for the subject of type "%s".', get_debug_type($subject))); + return $metadataBag[$key] ?? null; } } diff --git a/Metadata/InMemoryMetadataStore.php b/Metadata/InMemoryMetadataStore.php index 5e8b9c4..b88514b 100644 --- a/Metadata/InMemoryMetadataStore.php +++ b/Metadata/InMemoryMetadataStore.php @@ -20,15 +20,17 @@ final class InMemoryMetadataStore implements MetadataStoreInterface { use GetMetadataTrait; - private $workflowMetadata; - private $placesMetadata; - private $transitionsMetadata; - - public function __construct(array $workflowMetadata = [], array $placesMetadata = [], \SplObjectStorage $transitionsMetadata = null) - { - $this->workflowMetadata = $workflowMetadata; - $this->placesMetadata = $placesMetadata; - $this->transitionsMetadata = $transitionsMetadata ?: new \SplObjectStorage(); + private \SplObjectStorage $transitionsMetadata; + + /** + * @param \SplObjectStorage|null $transitionsMetadata + */ + public function __construct( + private array $workflowMetadata = [], + private array $placesMetadata = [], + ?\SplObjectStorage $transitionsMetadata = null, + ) { + $this->transitionsMetadata = $transitionsMetadata ?? new \SplObjectStorage(); } public function getWorkflowMetadata(): array diff --git a/Metadata/MetadataStoreInterface.php b/Metadata/MetadataStoreInterface.php index 4c4578a..e8f6b21 100644 --- a/Metadata/MetadataStoreInterface.php +++ b/Metadata/MetadataStoreInterface.php @@ -35,5 +35,5 @@ public function getTransitionMetadata(Transition $transition): array; * Use a string (the place name) to get place metadata * Use a Transition instance to get transition metadata */ - public function getMetadata(string $key, $subject = null); + public function getMetadata(string $key, string|Transition|null $subject = null): mixed; } diff --git a/README.md b/README.md index 068bd88..7813d63 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,21 @@ Workflow Component =================== +The Workflow component provides tools for managing a workflow or finite state +machine. + +Sponsor +------- + +Help Symfony by [sponsoring][1] its development! + Resources --------- - * [Documentation](https://symfony.com/doc/current/components/workflow.html) - * [Contributing](https://symfony.com/doc/current/contributing/index.html) - * [Report issues](https://github.com/symfony/symfony/issues) and - [send Pull Requests](https://github.com/symfony/symfony/pulls) - in the [main Symfony repository](https://github.com/symfony/symfony) + * [Documentation](https://symfony.com/doc/current/components/workflow.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) + +[1]: https://symfony.com/sponsor diff --git a/Registry.php b/Registry.php index 967db71..08017a3 100644 --- a/Registry.php +++ b/Registry.php @@ -20,16 +20,16 @@ */ class Registry { - private $workflows = []; + private array $workflows = []; - public function addWorkflow(WorkflowInterface $workflow, WorkflowSupportStrategyInterface $supportStrategy) + public function addWorkflow(WorkflowInterface $workflow, WorkflowSupportStrategyInterface $supportStrategy): void { $this->workflows[] = [$workflow, $supportStrategy]; } - public function has(object $subject, string $workflowName = null): bool + public function has(object $subject, ?string $workflowName = null): bool { - foreach ($this->workflows as list($workflow, $supportStrategy)) { + foreach ($this->workflows as [$workflow, $supportStrategy]) { if ($this->supports($workflow, $supportStrategy, $subject, $workflowName)) { return true; } @@ -38,29 +38,24 @@ public function has(object $subject, string $workflowName = null): bool return false; } - /** - * @return Workflow - */ - public function get(object $subject, string $workflowName = null) + public function get(object $subject, ?string $workflowName = null): WorkflowInterface { $matched = []; - foreach ($this->workflows as list($workflow, $supportStrategy)) { + foreach ($this->workflows as [$workflow, $supportStrategy]) { if ($this->supports($workflow, $supportStrategy, $subject, $workflowName)) { $matched[] = $workflow; } } if (!$matched) { - throw new InvalidArgumentException(sprintf('Unable to find a workflow for class "%s".', get_debug_type($subject))); + throw new InvalidArgumentException(\sprintf('Unable to find a workflow for class "%s".', get_debug_type($subject))); } if (2 <= \count($matched)) { - $names = array_map(static function (WorkflowInterface $workflow): string { - return $workflow->getName(); - }, $matched); + $names = array_map(static fn (WorkflowInterface $workflow): string => $workflow->getName(), $matched); - throw new InvalidArgumentException(sprintf('Too many workflows (%s) match this subject (%s); set a different name on each and use the second (name) argument of this method.', implode(', ', $names), get_debug_type($subject))); + throw new InvalidArgumentException(\sprintf('Too many workflows (%s) match this subject (%s); set a different name on each and use the second (name) argument of this method.', implode(', ', $names), get_debug_type($subject))); } return $matched[0]; @@ -72,7 +67,7 @@ public function get(object $subject, string $workflowName = null) public function all(object $subject): array { $matched = []; - foreach ($this->workflows as list($workflow, $supportStrategy)) { + foreach ($this->workflows as [$workflow, $supportStrategy]) { if ($supportStrategy->supports($workflow, $subject)) { $matched[] = $workflow; } diff --git a/StateMachine.php b/StateMachine.php index aa4f024..0946307 100644 --- a/StateMachine.php +++ b/StateMachine.php @@ -20,8 +20,8 @@ */ class StateMachine extends Workflow { - public function __construct(Definition $definition, MarkingStoreInterface $markingStore = null, EventDispatcherInterface $dispatcher = null, string $name = 'unnamed') + public function __construct(Definition $definition, ?MarkingStoreInterface $markingStore = null, ?EventDispatcherInterface $dispatcher = null, string $name = 'unnamed', ?array $eventsToDispatch = null) { - parent::__construct($definition, $markingStore ?: new MethodMarkingStore(true), $dispatcher, $name); + parent::__construct($definition, $markingStore ?? new MethodMarkingStore(true), $dispatcher, $name, $eventsToDispatch); } } diff --git a/SupportStrategy/InstanceOfSupportStrategy.php b/SupportStrategy/InstanceOfSupportStrategy.php index 487795e..8d8a4b5 100644 --- a/SupportStrategy/InstanceOfSupportStrategy.php +++ b/SupportStrategy/InstanceOfSupportStrategy.php @@ -19,16 +19,11 @@ */ final class InstanceOfSupportStrategy implements WorkflowSupportStrategyInterface { - private $className; - - public function __construct(string $className) - { - $this->className = $className; + public function __construct( + private string $className, + ) { } - /** - * {@inheritdoc} - */ public function supports(WorkflowInterface $workflow, object $subject): bool { return $subject instanceof $this->className; diff --git a/Tests/Attribute/AsListenerTest.php b/Tests/Attribute/AsListenerTest.php new file mode 100644 index 0000000..0a8c232 --- /dev/null +++ b/Tests/Attribute/AsListenerTest.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Tests\Attribute; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Workflow\Attribute; +use Symfony\Component\Workflow\Exception\LogicException; + +class AsListenerTest extends TestCase +{ + /** + * @dataProvider provideOkTests + */ + public function testOk(string $class, string $expectedEvent, ?string $workflow = null, ?string $node = null) + { + $attribute = new $class($workflow, $node); + + $this->assertSame($expectedEvent, $attribute->event); + } + + public static function provideOkTests(): iterable + { + yield [Attribute\AsAnnounceListener::class, 'workflow.announce']; + yield [Attribute\AsAnnounceListener::class, 'workflow.w.announce', 'w']; + yield [Attribute\AsAnnounceListener::class, 'workflow.w.announce.n', 'w', 'n']; + + yield [Attribute\AsCompletedListener::class, 'workflow.completed']; + yield [Attribute\AsCompletedListener::class, 'workflow.w.completed', 'w']; + yield [Attribute\AsCompletedListener::class, 'workflow.w.completed.n', 'w', 'n']; + + yield [Attribute\AsEnterListener::class, 'workflow.enter']; + yield [Attribute\AsEnterListener::class, 'workflow.w.enter', 'w']; + yield [Attribute\AsEnterListener::class, 'workflow.w.enter.n', 'w', 'n']; + + yield [Attribute\AsEnteredListener::class, 'workflow.entered']; + yield [Attribute\AsEnteredListener::class, 'workflow.w.entered', 'w']; + yield [Attribute\AsEnteredListener::class, 'workflow.w.entered.n', 'w', 'n']; + + yield [Attribute\AsGuardListener::class, 'workflow.guard']; + yield [Attribute\AsGuardListener::class, 'workflow.w.guard', 'w']; + yield [Attribute\AsGuardListener::class, 'workflow.w.guard.n', 'w', 'n']; + + yield [Attribute\AsLeaveListener::class, 'workflow.leave']; + yield [Attribute\AsLeaveListener::class, 'workflow.w.leave', 'w']; + yield [Attribute\AsLeaveListener::class, 'workflow.w.leave.n', 'w', 'n']; + + yield [Attribute\AsTransitionListener::class, 'workflow.transition']; + yield [Attribute\AsTransitionListener::class, 'workflow.w.transition', 'w']; + yield [Attribute\AsTransitionListener::class, 'workflow.w.transition.n', 'w', 'n']; + } + + /** + * @dataProvider provideTransitionThrowException + */ + public function testTransitionThrowException(string $class) + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(\sprintf('The "transition" argument of "%s" cannot be used without a "workflow" argument.', $class)); + + new $class(transition: 'some'); + } + + public static function provideTransitionThrowException(): iterable + { + yield [Attribute\AsAnnounceListener::class, 'workflow.announce']; + yield [Attribute\AsCompletedListener::class, 'workflow.completed']; + yield [Attribute\AsGuardListener::class, 'workflow.guard']; + yield [Attribute\AsTransitionListener::class, 'workflow.transition']; + } + + /** + * @dataProvider providePlaceThrowException + */ + public function testPlaceThrowException(string $class) + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(\sprintf('The "place" argument of "%s" cannot be used without a "workflow" argument.', $class)); + + new $class(place: 'some'); + } + + public static function providePlaceThrowException(): iterable + { + yield [Attribute\AsEnteredListener::class, 'workflow.entered']; + yield [Attribute\AsEnterListener::class, 'workflow.enter']; + yield [Attribute\AsLeaveListener::class, 'workflow.leave']; + } +} diff --git a/Tests/DataCollector/WorkflowDataCollectorTest.php b/Tests/DataCollector/WorkflowDataCollectorTest.php new file mode 100644 index 0000000..21b4fe6 --- /dev/null +++ b/Tests/DataCollector/WorkflowDataCollectorTest.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Tests\DataCollector; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; +use Symfony\Component\Workflow\DataCollector\WorkflowDataCollector; +use Symfony\Component\Workflow\EventListener\ExpressionLanguage; +use Symfony\Component\Workflow\EventListener\GuardListener; +use Symfony\Component\Workflow\Tests\WorkflowBuilderTrait; +use Symfony\Component\Workflow\Workflow; + +class WorkflowDataCollectorTest extends TestCase +{ + use WorkflowBuilderTrait; + + public function test() + { + $workflow1 = new Workflow($this->createComplexWorkflowDefinition(), name: 'workflow1'); + $workflow2 = new Workflow($this->createSimpleWorkflowDefinition(), name: 'workflow2'); + $dispatcher = new EventDispatcher(); + $dispatcher->addListener('workflow.workflow2.leave.a', fn () => true); + $dispatcher->addListener('workflow.workflow2.leave.a', [self::class, 'noop']); + $dispatcher->addListener('workflow.workflow2.leave.a', [$this, 'noop']); + $dispatcher->addListener('workflow.workflow2.leave.a', $this->noop(...)); + $dispatcher->addListener('workflow.workflow2.leave.a', 'var_dump'); + $guardListener = new GuardListener( + ['workflow.workflow2.guard.t1' => ['my_expression']], + $this->createMock(ExpressionLanguage::class), + $this->createMock(TokenStorageInterface::class), + $this->createMock(AuthorizationCheckerInterface::class), + $this->createMock(AuthenticationTrustResolverInterface::class), + $this->createMock(RoleHierarchyInterface::class), + $this->createMock(ValidatorInterface::class) + ); + $dispatcher->addListener('workflow.workflow2.guard.t1', [$guardListener, 'onTransition']); + + $collector = new WorkflowDataCollector( + [$workflow1, $workflow2], + $dispatcher, + new FileLinkFormatter(), + ); + + $collector->lateCollect(); + + $data = $collector->getWorkflows(); + + $this->assertArrayHasKey('workflow1', $data); + $this->assertArrayHasKey('dump', $data['workflow1']); + $this->assertStringStartsWith("graph LR\n", $data['workflow1']['dump']); + $this->assertArrayHasKey('listeners', $data['workflow1']); + + $this->assertSame([], $data['workflow1']['listeners']); + $this->assertArrayHasKey('workflow2', $data); + $this->assertArrayHasKey('dump', $data['workflow2']); + $this->assertStringStartsWith("graph LR\n", $data['workflow1']['dump']); + $this->assertArrayHasKey('listeners', $data['workflow2']); + $listeners = $data['workflow2']['listeners']; + $this->assertArrayHasKey('place0', $listeners); + $this->assertArrayHasKey('workflow.workflow2.leave.a', $listeners['place0']); + $descriptions = $listeners['place0']['workflow.workflow2.leave.a']; + $this->assertCount(5, $descriptions); + $this->assertStringContainsString('Closure', $descriptions[0]['title']); + $this->assertSame('Symfony\Component\Workflow\Tests\DataCollector\WorkflowDataCollectorTest::noop()', $descriptions[1]['title']); + $this->assertSame('Symfony\Component\Workflow\Tests\DataCollector\WorkflowDataCollectorTest::noop()', $descriptions[2]['title']); + $this->assertSame('Symfony\Component\Workflow\Tests\DataCollector\WorkflowDataCollectorTest::noop()', $descriptions[3]['title']); + $this->assertSame('var_dump()', $descriptions[4]['title']); + $this->assertArrayHasKey('transition0', $listeners); + $this->assertArrayHasKey('workflow.workflow2.guard.t1', $listeners['transition0']); + $this->assertSame('Symfony\Component\Workflow\EventListener\GuardListener::onTransition()', $listeners['transition0']['workflow.workflow2.guard.t1'][0]['title']); + $this->assertSame(['my_expression'], $listeners['transition0']['workflow.workflow2.guard.t1'][0]['guardExpressions']); + } + + public static function noop() + { + } +} diff --git a/Tests/Debug/TraceableWorkflowTest.php b/Tests/Debug/TraceableWorkflowTest.php new file mode 100644 index 0000000..257ad66 --- /dev/null +++ b/Tests/Debug/TraceableWorkflowTest.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Tests\Debug; + +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Stopwatch\Stopwatch; +use Symfony\Component\Workflow\Debug\TraceableWorkflow; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\TransitionBlockerList; +use Symfony\Component\Workflow\Workflow; + +class TraceableWorkflowTest extends TestCase +{ + private MockObject&Workflow $innerWorkflow; + + private Stopwatch $stopwatch; + + private TraceableWorkflow $traceableWorkflow; + + protected function setUp(): void + { + $this->innerWorkflow = $this->createMock(Workflow::class); + $this->stopwatch = new Stopwatch(); + + $this->traceableWorkflow = new TraceableWorkflow( + $this->innerWorkflow, + $this->stopwatch + ); + } + + /** + * @dataProvider provideFunctionNames + */ + public function testCallsInner(string $function, array $args, mixed $returnValue) + { + $this->innerWorkflow->expects($this->once()) + ->method($function) + ->willReturn($returnValue); + + $this->assertSame($returnValue, $this->traceableWorkflow->{$function}(...$args)); + + $calls = $this->traceableWorkflow->getCalls(); + + $this->assertCount(1, $calls); + $this->assertSame($function, $calls[0]['method']); + $this->assertArrayHasKey('duration', $calls[0]); + $this->assertSame($returnValue, $calls[0]['return']); + } + + public function testCallsInnerCatchesException() + { + $exception = new \Exception('foo'); + $this->innerWorkflow->expects($this->once()) + ->method('can') + ->willThrowException($exception); + + try { + $this->traceableWorkflow->can(new \stdClass(), 'foo'); + + $this->fail('An exception should have been thrown.'); + } catch (\Exception $e) { + $this->assertSame($exception, $e); + + $calls = $this->traceableWorkflow->getCalls(); + + $this->assertCount(1, $calls); + $this->assertSame('can', $calls[0]['method']); + $this->assertArrayHasKey('duration', $calls[0]); + $this->assertArrayHasKey('exception', $calls[0]); + $this->assertSame($exception, $calls[0]['exception']); + } + } + + public static function provideFunctionNames(): \Generator + { + $subject = new \stdClass(); + + yield ['getMarking', [$subject], new Marking(['place' => 1])]; + + yield ['can', [$subject, 'foo'], true]; + + yield ['buildTransitionBlockerList', [$subject, 'foo'], new TransitionBlockerList()]; + + yield ['apply', [$subject, 'foo'], new Marking(['place' => 1])]; + + yield ['getEnabledTransitions', [$subject], []]; + + yield ['getEnabledTransition', [$subject, 'foo'], null]; + } +} diff --git a/Tests/DefinitionBuilderTest.php b/Tests/DefinitionBuilderTest.php index c749011..2448d6b 100644 --- a/Tests/DefinitionBuilderTest.php +++ b/Tests/DefinitionBuilderTest.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests; use PHPUnit\Framework\TestCase; diff --git a/Tests/DefinitionTest.php b/Tests/DefinitionTest.php index 6ba3c1e..4303dee 100644 --- a/Tests/DefinitionTest.php +++ b/Tests/DefinitionTest.php @@ -1,9 +1,19 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests; use PHPUnit\Framework\TestCase; use Symfony\Component\Workflow\Definition; +use Symfony\Component\Workflow\Exception\LogicException; use Symfony\Component\Workflow\Transition; class DefinitionTest extends TestCase @@ -36,7 +46,7 @@ public function testSetInitialPlaces() public function testSetInitialPlaceAndPlaceIsNotDefined() { - $this->expectException('Symfony\Component\Workflow\Exception\LogicException'); + $this->expectException(LogicException::class); $this->expectExceptionMessage('Place "d" cannot be the initial place as it does not exist.'); new Definition([], [], 'd'); } @@ -54,19 +64,17 @@ public function testAddTransition() public function testAddTransitionAndFromPlaceIsNotDefined() { - $this->expectException('Symfony\Component\Workflow\Exception\LogicException'); - $this->expectExceptionMessage('Place "c" referenced in transition "name" does not exist.'); $places = range('a', 'b'); - new Definition($places, [new Transition('name', 'c', $places[1])]); + $definition = new Definition($places, [new Transition('name', 'c', $places[1])]); + $this->assertContains('c', $definition->getPlaces()); } public function testAddTransitionAndToPlaceIsNotDefined() { - $this->expectException('Symfony\Component\Workflow\Exception\LogicException'); - $this->expectExceptionMessage('Place "c" referenced in transition "name" does not exist.'); $places = range('a', 'b'); - new Definition($places, [new Transition('name', $places[0], 'c')]); + $definition = new Definition($places, [new Transition('name', $places[0], 'c')]); + $this->assertContains('c', $definition->getPlaces()); } } diff --git a/Tests/DependencyInjection/WorkflowGuardListenerPassTest.php b/Tests/DependencyInjection/WorkflowGuardListenerPassTest.php new file mode 100644 index 0000000..4e69a9c --- /dev/null +++ b/Tests/DependencyInjection/WorkflowGuardListenerPassTest.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Tests\DependencyInjection; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\LogicException; +use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Role\RoleHierarchy; +use Symfony\Component\Validator\Validator\ValidatorInterface; +use Symfony\Component\Workflow\DependencyInjection\WorkflowGuardListenerPass; + +class WorkflowGuardListenerPassTest extends TestCase +{ + private ContainerBuilder $container; + private WorkflowGuardListenerPass $compilerPass; + + protected function setUp(): void + { + $this->container = new ContainerBuilder(); + $this->compilerPass = new WorkflowGuardListenerPass(); + } + + public function testNoExeptionIfParameterIsNotSet() + { + $this->compilerPass->process($this->container); + + $this->assertFalse($this->container->hasParameter('workflow.has_guard_listeners')); + } + + public function testNoExeptionIfAllDependenciesArePresent() + { + $this->container->setParameter('workflow.has_guard_listeners', true); + $this->container->register('security.token_storage', TokenStorageInterface::class); + $this->container->register('security.authorization_checker', AuthorizationCheckerInterface::class); + $this->container->register('security.authentication.trust_resolver', AuthenticationTrustResolverInterface::class); + $this->container->register('security.role_hierarchy', RoleHierarchy::class); + $this->container->register('validator', ValidatorInterface::class); + + $this->compilerPass->process($this->container); + + $this->assertFalse($this->container->hasParameter('workflow.has_guard_listeners')); + } + + public function testExceptionIfTheTokenStorageServiceIsNotPresent() + { + $this->container->setParameter('workflow.has_guard_listeners', true); + $this->container->register('security.authorization_checker', AuthorizationCheckerInterface::class); + $this->container->register('security.authentication.trust_resolver', AuthenticationTrustResolverInterface::class); + $this->container->register('security.role_hierarchy', RoleHierarchy::class); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The "security.token_storage" service is needed to be able to use the workflow guard listener.'); + + $this->compilerPass->process($this->container); + } + + public function testExceptionIfTheAuthorizationCheckerServiceIsNotPresent() + { + $this->container->setParameter('workflow.has_guard_listeners', true); + $this->container->register('security.token_storage', TokenStorageInterface::class); + $this->container->register('security.authentication.trust_resolver', AuthenticationTrustResolverInterface::class); + $this->container->register('security.role_hierarchy', RoleHierarchy::class); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The "security.authorization_checker" service is needed to be able to use the workflow guard listener.'); + + $this->compilerPass->process($this->container); + } + + public function testExceptionIfTheAuthenticationTrustResolverServiceIsNotPresent() + { + $this->container->setParameter('workflow.has_guard_listeners', true); + $this->container->register('security.token_storage', TokenStorageInterface::class); + $this->container->register('security.authorization_checker', AuthorizationCheckerInterface::class); + $this->container->register('security.role_hierarchy', RoleHierarchy::class); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The "security.authentication.trust_resolver" service is needed to be able to use the workflow guard listener.'); + + $this->compilerPass->process($this->container); + } + + public function testExceptionIfTheRoleHierarchyServiceIsNotPresent() + { + $this->container->setParameter('workflow.has_guard_listeners', true); + $this->container->register('security.token_storage', TokenStorageInterface::class); + $this->container->register('security.authorization_checker', AuthorizationCheckerInterface::class); + $this->container->register('security.authentication.trust_resolver', AuthenticationTrustResolverInterface::class); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The "security.role_hierarchy" service is needed to be able to use the workflow guard listener.'); + + $this->compilerPass->process($this->container); + } +} diff --git a/Tests/DependencyInjection/WorkflowValidatorPassTest.php b/Tests/DependencyInjection/WorkflowValidatorPassTest.php new file mode 100644 index 0000000..213e0d4 --- /dev/null +++ b/Tests/DependencyInjection/WorkflowValidatorPassTest.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Tests\DependencyInjection; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\Workflow\Definition; +use Symfony\Component\Workflow\DependencyInjection\WorkflowValidatorPass; +use Symfony\Component\Workflow\Validator\DefinitionValidatorInterface; +use Symfony\Component\Workflow\WorkflowInterface; + +class WorkflowValidatorPassTest extends TestCase +{ + private ContainerBuilder $container; + private WorkflowValidatorPass $compilerPass; + + protected function setUp(): void + { + $this->container = new ContainerBuilder(); + $this->compilerPass = new WorkflowValidatorPass(); + } + + public function testNothingToDo() + { + $this->compilerPass->process($this->container); + + $this->assertFalse(DefinitionValidator::$called); + } + + public function testValidate() + { + $this + ->container + ->register('my.workflow', WorkflowInterface::class) + ->addTag('workflow', [ + 'definition_id' => 'my.workflow.definition', + 'name' => 'my.workflow', + 'definition_validators' => [DefinitionValidator::class], + ]) + ; + + $this + ->container + ->register('my.workflow.definition', Definition::class) + ->setArguments([ + '$places' => [], + '$transitions' => [], + ]) + ; + + $this->compilerPass->process($this->container); + + $this->assertTrue(DefinitionValidator::$called); + } +} + +class DefinitionValidator implements DefinitionValidatorInterface +{ + public static bool $called = false; + + public function validate(Definition $definition, string $name): void + { + self::$called = true; + } +} diff --git a/Tests/Dumper/GraphvizDumperTest.php b/Tests/Dumper/GraphvizDumperTest.php index a8ca581..9356715 100644 --- a/Tests/Dumper/GraphvizDumperTest.php +++ b/Tests/Dumper/GraphvizDumperTest.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests\Dumper; use PHPUnit\Framework\TestCase; @@ -11,19 +20,12 @@ class GraphvizDumperTest extends TestCase { use WorkflowBuilderTrait; - private $dumper; - - protected function setUp(): void - { - $this->dumper = new GraphvizDumper(); - } - /** * @dataProvider provideWorkflowDefinitionWithoutMarking */ - public function testDumpWithoutMarking($definition, $expected) + public function testDumpWithoutMarking($definition, $expected, $withMetadata) { - $dump = $this->dumper->dump($definition); + $dump = (new GraphvizDumper())->dump($definition, null, ['with-metadata' => $withMetadata]); $this->assertEquals($expected, $dump); } @@ -31,35 +33,53 @@ public function testDumpWithoutMarking($definition, $expected) /** * @dataProvider provideWorkflowDefinitionWithMarking */ - public function testDumpWithMarking($definition, $marking, $expected) + public function testDumpWithMarking($definition, $marking, $expected, $withMetadata) { - $dump = $this->dumper->dump($definition, $marking); + $dump = (new GraphvizDumper())->dump($definition, $marking, ['with-metadata' => $withMetadata]); $this->assertEquals($expected, $dump); } - public function provideWorkflowDefinitionWithMarking() + public static function provideWorkflowDefinitionWithoutMarking(): \Generator + { + yield [self::createComplexWorkflowDefinition(), self::provideComplexWorkflowDumpWithoutMarking(), false]; + yield [self::createSimpleWorkflowDefinition(), self::provideSimpleWorkflowDumpWithoutMarking(), false]; + yield [self::createComplexWorkflowDefinition(), self::provideComplexWorkflowDumpWithoutMarkingWithMetadata(), true]; + yield [self::createSimpleWorkflowDefinition(), self::provideSimpleWorkflowDumpWithoutMarkingWithMetadata(), true]; + } + + public static function provideWorkflowDefinitionWithMarking(): \Generator { yield [ - $this->createComplexWorkflowDefinition(), + self::createComplexWorkflowDefinition(), new Marking(['b' => 1]), - $this->createComplexWorkflowDefinitionDumpWithMarking(), + self::createComplexWorkflowDefinitionDumpWithMarking(), + false, ]; yield [ - $this->createSimpleWorkflowDefinition(), + self::createSimpleWorkflowDefinition(), new Marking(['c' => 1, 'd' => 1]), - $this->createSimpleWorkflowDumpWithMarking(), + self::createSimpleWorkflowDumpWithMarking(), + false, ]; - } - public function provideWorkflowDefinitionWithoutMarking() - { - yield [$this->createComplexWorkflowDefinition(), $this->provideComplexWorkflowDumpWithoutMarking()]; - yield [$this->createSimpleWorkflowDefinition(), $this->provideSimpleWorkflowDumpWithoutMarking()]; + yield [ + self::createComplexWorkflowDefinition(), + new Marking(['b' => 1]), + self::createComplexWorkflowDefinitionDumpWithMarkingAndMetadata(), + true, + ]; + + yield [ + self::createSimpleWorkflowDefinition(), + new Marking(['c' => 1, 'd' => 1]), + self::createSimpleWorkflowDumpWithMarkingAndMetadata(), + true, + ]; } - public function createComplexWorkflowDefinitionDumpWithMarking() + public static function createComplexWorkflowDefinitionDumpWithMarking(): string { return 'digraph workflow { ratio="compress" rankdir="LR" @@ -97,7 +117,45 @@ public function createComplexWorkflowDefinitionDumpWithMarking() '; } - public function createSimpleWorkflowDumpWithMarking() + public static function createComplexWorkflowDefinitionDumpWithMarkingAndMetadata(): string + { + return 'digraph workflow { + ratio="compress" rankdir="LR" + node [fontsize="9" fontname="Arial" color="#333333" fillcolor="lightblue" fixedsize="false" width="1"]; + edge [fontsize="9" fontname="Arial" color="#333333" arrowhead="normal" arrowsize="0.5"]; + + place_86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 [label=<a>, shape=circle style="filled"]; + place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 [label=<b>, shape=circle color="#FF0000" shape="doublecircle"]; + place_84a516841ba77a5b4648de2cd0dfcb30ea46dbb4 [label=<c>, shape=circle]; + place_3c363836cf4e16666669a25da280a1865c2d2874 [label=<d>, shape=circle]; + place_58e6b3a414a1e090dfc6029add0f3555ccba127f [label=<e>, shape=circle]; + place_4a0a19218e082a343a1b17e5333409af9d98f0f5 [label=<f>, shape=circle]; + place_54fd1711209fb1c0781092374132c66e79e2241b [label=<g>, shape=circle]; + transition_b6589fc6ab0dc82cf12099d1c2d40ab994e8410c [label=<t1>, shape="box" regular="1"]; + transition_356a192b7913b04c54574d18c28d46e6395428ab [label=<t2>, shape="box" regular="1"]; + transition_da4b9237bacccdf19c0760cab7aec4a8359010b0 [label=<My custom transition label 1
color: Red
arrow_color: Green>, shape="box" regular="1"]; + transition_77de68daecd823babbb58edb1c8e14d7106e83bb [label=<t4>, shape="box" regular="1"]; + transition_1b6453892473a467d07372d45eb05abc2031647a [label=<t5>, shape="box" regular="1"]; + transition_ac3478d69a3c81fa62e60f5c3696165a4e5e6ac4 [label=<t6>, shape="box" regular="1"]; + place_86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 -> transition_b6589fc6ab0dc82cf12099d1c2d40ab994e8410c [style="solid"]; + transition_b6589fc6ab0dc82cf12099d1c2d40ab994e8410c -> place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 [style="solid"]; + transition_b6589fc6ab0dc82cf12099d1c2d40ab994e8410c -> place_84a516841ba77a5b4648de2cd0dfcb30ea46dbb4 [style="solid"]; + place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 -> transition_356a192b7913b04c54574d18c28d46e6395428ab [style="solid"]; + place_84a516841ba77a5b4648de2cd0dfcb30ea46dbb4 -> transition_356a192b7913b04c54574d18c28d46e6395428ab [style="solid"]; + transition_356a192b7913b04c54574d18c28d46e6395428ab -> place_3c363836cf4e16666669a25da280a1865c2d2874 [style="solid"]; + place_3c363836cf4e16666669a25da280a1865c2d2874 -> transition_da4b9237bacccdf19c0760cab7aec4a8359010b0 [style="solid"]; + transition_da4b9237bacccdf19c0760cab7aec4a8359010b0 -> place_58e6b3a414a1e090dfc6029add0f3555ccba127f [style="solid"]; + place_3c363836cf4e16666669a25da280a1865c2d2874 -> transition_77de68daecd823babbb58edb1c8e14d7106e83bb [style="solid"]; + transition_77de68daecd823babbb58edb1c8e14d7106e83bb -> place_4a0a19218e082a343a1b17e5333409af9d98f0f5 [style="solid"]; + place_58e6b3a414a1e090dfc6029add0f3555ccba127f -> transition_1b6453892473a467d07372d45eb05abc2031647a [style="solid"]; + transition_1b6453892473a467d07372d45eb05abc2031647a -> place_54fd1711209fb1c0781092374132c66e79e2241b [style="solid"]; + place_4a0a19218e082a343a1b17e5333409af9d98f0f5 -> transition_ac3478d69a3c81fa62e60f5c3696165a4e5e6ac4 [style="solid"]; + transition_ac3478d69a3c81fa62e60f5c3696165a4e5e6ac4 -> place_54fd1711209fb1c0781092374132c66e79e2241b [style="solid"]; +} +'; + } + + public static function createSimpleWorkflowDumpWithMarking(): string { return 'digraph workflow { ratio="compress" rankdir="LR" @@ -117,7 +175,27 @@ public function createSimpleWorkflowDumpWithMarking() '; } - public function provideComplexWorkflowDumpWithoutMarking() + public static function createSimpleWorkflowDumpWithMarkingAndMetadata(): string + { + return 'digraph workflow { + ratio="compress" rankdir="LR" + node [fontsize="9" fontname="Arial" color="#333333" fillcolor="lightblue" fixedsize="false" width="1"]; + edge [fontsize="9" fontname="Arial" color="#333333" arrowhead="normal" arrowsize="0.5"]; + + place_86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 [label=<a>, shape=circle style="filled"]; + place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 [label=<b>, shape=circle]; + place_84a516841ba77a5b4648de2cd0dfcb30ea46dbb4 [label=<c
bg_color: DeepSkyBlue
description: My custom place description>, shape=circle color="#FF0000" shape="doublecircle" style="filled" fillcolor="DeepSkyBlue"]; + transition_b6589fc6ab0dc82cf12099d1c2d40ab994e8410c [label=<My custom transition label 2
color: Grey
arrow_color: Purple>, shape="box" regular="1"]; + transition_356a192b7913b04c54574d18c28d46e6395428ab [label=<t2
arrow_color: Pink>, shape="box" regular="1"]; + place_86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 -> transition_b6589fc6ab0dc82cf12099d1c2d40ab994e8410c [style="solid"]; + transition_b6589fc6ab0dc82cf12099d1c2d40ab994e8410c -> place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 [style="solid"]; + place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 -> transition_356a192b7913b04c54574d18c28d46e6395428ab [style="solid"]; + transition_356a192b7913b04c54574d18c28d46e6395428ab -> place_84a516841ba77a5b4648de2cd0dfcb30ea46dbb4 [style="solid"]; +} +'; + } + + public static function provideComplexWorkflowDumpWithoutMarking(): string { return 'digraph workflow { ratio="compress" rankdir="LR" @@ -155,7 +233,45 @@ public function provideComplexWorkflowDumpWithoutMarking() '; } - public function provideSimpleWorkflowDumpWithoutMarking() + public static function provideComplexWorkflowDumpWithoutMarkingWithMetadata(): string + { + return 'digraph workflow { + ratio="compress" rankdir="LR" + node [fontsize="9" fontname="Arial" color="#333333" fillcolor="lightblue" fixedsize="false" width="1"]; + edge [fontsize="9" fontname="Arial" color="#333333" arrowhead="normal" arrowsize="0.5"]; + + place_86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 [label=<a>, shape=circle style="filled"]; + place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 [label=<b>, shape=circle]; + place_84a516841ba77a5b4648de2cd0dfcb30ea46dbb4 [label=<c>, shape=circle]; + place_3c363836cf4e16666669a25da280a1865c2d2874 [label=<d>, shape=circle]; + place_58e6b3a414a1e090dfc6029add0f3555ccba127f [label=<e>, shape=circle]; + place_4a0a19218e082a343a1b17e5333409af9d98f0f5 [label=<f>, shape=circle]; + place_54fd1711209fb1c0781092374132c66e79e2241b [label=<g>, shape=circle]; + transition_b6589fc6ab0dc82cf12099d1c2d40ab994e8410c [label=<t1>, shape="box" regular="1"]; + transition_356a192b7913b04c54574d18c28d46e6395428ab [label=<t2>, shape="box" regular="1"]; + transition_da4b9237bacccdf19c0760cab7aec4a8359010b0 [label=<My custom transition label 1
color: Red
arrow_color: Green>, shape="box" regular="1"]; + transition_77de68daecd823babbb58edb1c8e14d7106e83bb [label=<t4>, shape="box" regular="1"]; + transition_1b6453892473a467d07372d45eb05abc2031647a [label=<t5>, shape="box" regular="1"]; + transition_ac3478d69a3c81fa62e60f5c3696165a4e5e6ac4 [label=<t6>, shape="box" regular="1"]; + place_86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 -> transition_b6589fc6ab0dc82cf12099d1c2d40ab994e8410c [style="solid"]; + transition_b6589fc6ab0dc82cf12099d1c2d40ab994e8410c -> place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 [style="solid"]; + transition_b6589fc6ab0dc82cf12099d1c2d40ab994e8410c -> place_84a516841ba77a5b4648de2cd0dfcb30ea46dbb4 [style="solid"]; + place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 -> transition_356a192b7913b04c54574d18c28d46e6395428ab [style="solid"]; + place_84a516841ba77a5b4648de2cd0dfcb30ea46dbb4 -> transition_356a192b7913b04c54574d18c28d46e6395428ab [style="solid"]; + transition_356a192b7913b04c54574d18c28d46e6395428ab -> place_3c363836cf4e16666669a25da280a1865c2d2874 [style="solid"]; + place_3c363836cf4e16666669a25da280a1865c2d2874 -> transition_da4b9237bacccdf19c0760cab7aec4a8359010b0 [style="solid"]; + transition_da4b9237bacccdf19c0760cab7aec4a8359010b0 -> place_58e6b3a414a1e090dfc6029add0f3555ccba127f [style="solid"]; + place_3c363836cf4e16666669a25da280a1865c2d2874 -> transition_77de68daecd823babbb58edb1c8e14d7106e83bb [style="solid"]; + transition_77de68daecd823babbb58edb1c8e14d7106e83bb -> place_4a0a19218e082a343a1b17e5333409af9d98f0f5 [style="solid"]; + place_58e6b3a414a1e090dfc6029add0f3555ccba127f -> transition_1b6453892473a467d07372d45eb05abc2031647a [style="solid"]; + transition_1b6453892473a467d07372d45eb05abc2031647a -> place_54fd1711209fb1c0781092374132c66e79e2241b [style="solid"]; + place_4a0a19218e082a343a1b17e5333409af9d98f0f5 -> transition_ac3478d69a3c81fa62e60f5c3696165a4e5e6ac4 [style="solid"]; + transition_ac3478d69a3c81fa62e60f5c3696165a4e5e6ac4 -> place_54fd1711209fb1c0781092374132c66e79e2241b [style="solid"]; +} +'; + } + + public static function provideSimpleWorkflowDumpWithoutMarking(): string { return 'digraph workflow { ratio="compress" rankdir="LR" @@ -172,6 +288,26 @@ public function provideSimpleWorkflowDumpWithoutMarking() place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 -> transition_356a192b7913b04c54574d18c28d46e6395428ab [style="solid"]; transition_356a192b7913b04c54574d18c28d46e6395428ab -> place_84a516841ba77a5b4648de2cd0dfcb30ea46dbb4 [style="solid"]; } +'; + } + + public static function provideSimpleWorkflowDumpWithoutMarkingWithMetadata(): string + { + return 'digraph workflow { + ratio="compress" rankdir="LR" + node [fontsize="9" fontname="Arial" color="#333333" fillcolor="lightblue" fixedsize="false" width="1"]; + edge [fontsize="9" fontname="Arial" color="#333333" arrowhead="normal" arrowsize="0.5"]; + + place_86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 [label=<a>, shape=circle style="filled"]; + place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 [label=<b>, shape=circle]; + place_84a516841ba77a5b4648de2cd0dfcb30ea46dbb4 [label=<c
bg_color: DeepSkyBlue
description: My custom place description>, shape=circle style="filled" fillcolor="DeepSkyBlue"]; + transition_b6589fc6ab0dc82cf12099d1c2d40ab994e8410c [label=<My custom transition label 2
color: Grey
arrow_color: Purple>, shape="box" regular="1"]; + transition_356a192b7913b04c54574d18c28d46e6395428ab [label=<t2
arrow_color: Pink>, shape="box" regular="1"]; + place_86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 -> transition_b6589fc6ab0dc82cf12099d1c2d40ab994e8410c [style="solid"]; + transition_b6589fc6ab0dc82cf12099d1c2d40ab994e8410c -> place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 [style="solid"]; + place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 -> transition_356a192b7913b04c54574d18c28d46e6395428ab [style="solid"]; + transition_356a192b7913b04c54574d18c28d46e6395428ab -> place_84a516841ba77a5b4648de2cd0dfcb30ea46dbb4 [style="solid"]; +} '; } } diff --git a/Tests/Dumper/MermaidDumperTest.php b/Tests/Dumper/MermaidDumperTest.php new file mode 100644 index 0000000..a8d1978 --- /dev/null +++ b/Tests/Dumper/MermaidDumperTest.php @@ -0,0 +1,218 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Tests\Dumper; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Workflow\Definition; +use Symfony\Component\Workflow\DefinitionBuilder; +use Symfony\Component\Workflow\Dumper\MermaidDumper; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Tests\WorkflowBuilderTrait; +use Symfony\Component\Workflow\Transition; + +class MermaidDumperTest extends TestCase +{ + use WorkflowBuilderTrait; + + /** + * @dataProvider provideWorkflowDefinitionWithoutMarking + */ + public function testDumpWithoutMarking(Definition $definition, string $expected) + { + $dumper = new MermaidDumper(MermaidDumper::TRANSITION_TYPE_WORKFLOW); + + $dump = $dumper->dump($definition); + + $this->assertEquals($expected, $dump); + } + + /** + * @dataProvider provideWorkflowWithReservedWords + */ + public function testDumpWithReservedWordsAsPlacenames(Definition $definition, string $expected) + { + $dumper = new MermaidDumper(MermaidDumper::TRANSITION_TYPE_WORKFLOW); + + $dump = $dumper->dump($definition); + + $this->assertEquals($expected, $dump); + } + + /** + * @dataProvider provideStateMachine + */ + public function testDumpAsStateMachine(Definition $definition, string $expected) + { + $dumper = new MermaidDumper(MermaidDumper::TRANSITION_TYPE_STATEMACHINE); + + $dump = $dumper->dump($definition); + + $this->assertEquals($expected, $dump); + } + + /** + * @dataProvider provideWorkflowWithMarking + */ + public function testDumpWorkflowWithMarking(Definition $definition, Marking $marking, string $expected) + { + $dumper = new MermaidDumper(MermaidDumper::TRANSITION_TYPE_WORKFLOW); + + $dump = $dumper->dump($definition, $marking); + + $this->assertEquals($expected, $dump); + } + + public static function provideWorkflowDefinitionWithoutMarking(): iterable + { + yield [ + self::createComplexWorkflowDefinition(), + "graph LR\n" + ."place0([\"a\"])\n" + ."place1((\"b\"))\n" + ."place2((\"c\"))\n" + ."place3((\"d\"))\n" + ."place4((\"e\"))\n" + ."place5((\"f\"))\n" + ."place6((\"g\"))\n" + ."transition0[\"t1\"]\n" + ."place0-->transition0\n" + ."transition0-->place1\n" + ."transition0-->place2\n" + ."transition1[\"t2\"]\n" + ."place1-->transition1\n" + ."transition1-->place3\n" + ."place2-->transition1\n" + ."transition2[\"My custom transition label 1\"]\n" + ."place3-->transition2\n" + ."linkStyle 6 stroke:Red\n" + ."transition2-->place4\n" + ."linkStyle 7 stroke:Red\n" + ."transition3[\"t4\"]\n" + ."place3-->transition3\n" + ."transition3-->place5\n" + ."transition4[\"t5\"]\n" + ."place4-->transition4\n" + ."transition4-->place6\n" + ."transition5[\"t6\"]\n" + ."place5-->transition5\n" + .'transition5-->place6', + ]; + yield [ + self::createWorkflowWithSameNameTransition(), + "graph LR\n" + ."place0([\"a\"])\n" + ."place1((\"b\"))\n" + ."place2((\"c\"))\n" + ."transition0[\"a_to_bc\"]\n" + ."place0-->transition0\n" + ."transition0-->place1\n" + ."transition0-->place2\n" + ."transition1[\"b_to_c\"]\n" + ."place1-->transition1\n" + ."transition1-->place2\n" + ."transition2[\"to_a\"]\n" + ."place1-->transition2\n" + ."transition2-->place0\n" + ."transition3[\"to_a\"]\n" + ."place2-->transition3\n" + .'transition3-->place0', + ]; + yield [ + self::createSimpleWorkflowDefinition(), + "graph LR\n" + ."place0([\"a\"])\n" + ."place1((\"b\"))\n" + ."place2((\"c\"))\n" + ."style place2 fill:DeepSkyBlue\n" + ."transition0[\"My custom transition label 2\"]\n" + ."place0-->transition0\n" + ."linkStyle 0 stroke:Grey\n" + ."transition0-->place1\n" + ."linkStyle 1 stroke:Grey\n" + ."transition1[\"t2\"]\n" + ."place1-->transition1\n" + .'transition1-->place2', + ]; + } + + public static function provideWorkflowWithReservedWords(): iterable + { + $builder = new DefinitionBuilder(); + + $builder->addPlaces(['start', 'subgraph', 'end', 'finis']); + $builder->addTransitions([ + new Transition('t0', ['start', 'subgraph'], ['end']), + new Transition('t1', ['end'], ['finis']), + ]); + + $definition = $builder->build(); + + yield [ + $definition, + "graph LR\n" + ."place0([\"start\"])\n" + ."place1((\"subgraph\"))\n" + ."place2((\"end\"))\n" + ."place3((\"finis\"))\n" + ."transition0[\"t0\"]\n" + ."place0-->transition0\n" + ."transition0-->place2\n" + ."place1-->transition0\n" + ."transition1[\"t1\"]\n" + ."place2-->transition1\n" + .'transition1-->place3', + ]; + } + + public static function provideStateMachine(): iterable + { + yield [ + self::createComplexStateMachineDefinition(), + "graph LR\n" + ."place0([\"a\"])\n" + ."place1((\"b\"))\n" + ."place2((\"c\"))\n" + ."place3((\"d\"))\n" + ."place0-->|\"t1\"|place1\n" + ."place3-->|\"My custom transition label 3\"|place1\n" + ."linkStyle 1 stroke:Grey\n" + ."place1-->|\"t2\"|place2\n" + .'place1-->|"t3"|place3', + ]; + } + + public static function provideWorkflowWithMarking(): iterable + { + $marking = new Marking(); + $marking->mark('b'); + $marking->mark('c'); + + yield [ + self::createSimpleWorkflowDefinition(), + $marking, + "graph LR\n" + ."place0([\"a\"])\n" + ."place1((\"b\"))\n" + ."style place1 stroke-width:4px\n" + ."place2((\"c\"))\n" + ."style place2 fill:DeepSkyBlue,stroke-width:4px\n" + ."transition0[\"My custom transition label 2\"]\n" + ."place0-->transition0\n" + ."linkStyle 0 stroke:Grey\n" + ."transition0-->place1\n" + ."linkStyle 1 stroke:Grey\n" + ."transition1[\"t2\"]\n" + ."place1-->transition1\n" + .'transition1-->place2', + ]; + } +} diff --git a/Tests/Dumper/PlantUmlDumperTest.php b/Tests/Dumper/PlantUmlDumperTest.php index c8c285b..a018a4e 100644 --- a/Tests/Dumper/PlantUmlDumperTest.php +++ b/Tests/Dumper/PlantUmlDumperTest.php @@ -1,4 +1,5 @@ dump($definition, $marking, ['title' => $title]); // handle windows, and avoid to create more fixtures - $dump = str_replace(PHP_EOL, "\n", $dump.PHP_EOL); + $dump = str_replace(\PHP_EOL, "\n", $dump.\PHP_EOL); $file = $this->getFixturePath($expectedFileName, PlantUmlDumper::WORKFLOW_TRANSITION); $this->assertStringEqualsFile($file, $dump); } - public function provideWorkflowDefinitionWithoutMarking() + public static function provideWorkflowDefinitionWithoutMarking(): \Generator { - yield [$this->createSimpleWorkflowDefinition(), null, 'simple-workflow-nomarking', 'SimpleDiagram']; - yield [$this->createComplexWorkflowDefinition(), null, 'complex-workflow-nomarking', 'ComplexDiagram']; + yield [self::createSimpleWorkflowDefinition(), null, 'simple-workflow-nomarking', 'SimpleDiagram']; + yield [self::createComplexWorkflowDefinition(), null, 'complex-workflow-nomarking', 'ComplexDiagram']; $marking = new Marking(['b' => 1]); - yield [$this->createSimpleWorkflowDefinition(), $marking, 'simple-workflow-marking', 'SimpleDiagram']; + yield [self::createSimpleWorkflowDefinition(), $marking, 'simple-workflow-marking', 'SimpleDiagram']; $marking = new Marking(['c' => 1, 'e' => 1]); - yield [$this->createComplexWorkflowDefinition(), $marking, 'complex-workflow-marking', 'ComplexDiagram']; + yield [self::createComplexWorkflowDefinition(), $marking, 'complex-workflow-marking', 'ComplexDiagram']; } /** @@ -50,20 +54,48 @@ public function testDumpStateMachineWithoutMarking($definition, $marking, $expec $dumper = new PlantUmlDumper(PlantUmlDumper::STATEMACHINE_TRANSITION); $dump = $dumper->dump($definition, $marking, ['title' => $title]); // handle windows, and avoid to create more fixtures - $dump = str_replace(PHP_EOL, "\n", $dump.PHP_EOL); + $dump = str_replace(\PHP_EOL, "\n", $dump.\PHP_EOL); $file = $this->getFixturePath($expectedFileName, PlantUmlDumper::STATEMACHINE_TRANSITION); $this->assertStringEqualsFile($file, $dump); } - public function provideStateMachineDefinitionWithoutMarking() + public static function provideStateMachineDefinitionWithoutMarking(): \Generator { - yield [$this->createComplexStateMachineDefinition(), null, 'complex-state-machine-nomarking', 'SimpleDiagram']; + yield [static::createComplexStateMachineDefinition(), null, 'complex-state-machine-nomarking', 'SimpleDiagram']; $marking = new Marking(['c' => 1, 'e' => 1]); - yield [$this->createComplexStateMachineDefinition(), $marking, 'complex-state-machine-marking', 'SimpleDiagram']; + yield [static::createComplexStateMachineDefinition(), $marking, 'complex-state-machine-marking', 'SimpleDiagram']; + } + + public function testDumpWorkflowWithSpacesInTheStateNamesAndDescription() + { + $dumper = new PlantUmlDumper(PlantUmlDumper::WORKFLOW_TRANSITION); + + // The graph looks like: + // + // +---------+ t 1 +----------+ | + // | place a | -----> | place b | | + // +---------+ +----------+ | + $places = ['place a', 'place b']; + + $transitions = []; + $transition = new Transition('t 1', 'place a', 'place b'); + $transitions[] = $transition; + + $placesMetadata = []; + $placesMetadata['place a'] = [ + 'description' => "My custom\nplace description", + ]; + $inMemoryMetadataStore = new InMemoryMetadataStore([], $placesMetadata); + $definition = new Definition($places, $transitions, null, $inMemoryMetadataStore); + + $dump = $dumper->dump($definition, null, ['title' => 'SimpleDiagram']); + $dump = str_replace(\PHP_EOL, "\n", $dump.\PHP_EOL); + $file = $this->getFixturePath('simple-workflow-with-spaces', PlantUmlDumper::WORKFLOW_TRANSITION); + $this->assertStringEqualsFile($file, $dump); } - private function getFixturePath($name, $transitionType) + private function getFixturePath($name, $transitionType): string { - return __DIR__.'/../fixtures/puml/'.$transitionType.'/'.$name.'.puml'; + return __DIR__.'/../Fixtures/puml/'.$transitionType.'/'.$name.'.puml'; } } diff --git a/Tests/Dumper/StateMachineGraphvizDumperTest.php b/Tests/Dumper/StateMachineGraphvizDumperTest.php index 375aa9f..20b3694 100644 --- a/Tests/Dumper/StateMachineGraphvizDumperTest.php +++ b/Tests/Dumper/StateMachineGraphvizDumperTest.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests\Dumper; use PHPUnit\Framework\TestCase; @@ -11,18 +20,11 @@ class StateMachineGraphvizDumperTest extends TestCase { use WorkflowBuilderTrait; - private $dumper; - - protected function setUp(): void - { - $this->dumper = new StateMachineGraphvizDumper(); - } - public function testDumpWithoutMarking() { $definition = $this->createComplexStateMachineDefinition(); - $dump = $this->dumper->dump($definition); + $dump = (new StateMachineGraphvizDumper())->dump($definition); $expected = <<<'EOGRAPH' digraph workflow { @@ -35,7 +37,8 @@ public function testDumpWithoutMarking() place_84a516841ba77a5b4648de2cd0dfcb30ea46dbb4 [label="c", shape=circle]; place_3c363836cf4e16666669a25da280a1865c2d2874 [label="d", shape=circle]; place_86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 -> place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 [label="t1" style="solid"]; - place_3c363836cf4e16666669a25da280a1865c2d2874 -> place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 [label="My custom transition label 3" style="solid" fontcolor="Grey" color="Red"]; + place_3c363836cf4e16666669a25da280a1865c2d2874 -> place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 [label="My custom transition +label 3" style="solid" fontcolor="Grey" color="Red"]; place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 -> place_84a516841ba77a5b4648de2cd0dfcb30ea46dbb4 [label="t2" style="solid" color="Blue"]; place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 -> place_3c363836cf4e16666669a25da280a1865c2d2874 [label="t3" style="solid"]; } @@ -61,14 +64,15 @@ public function testDumpWithMarking() place_84a516841ba77a5b4648de2cd0dfcb30ea46dbb4 [label="c", shape=circle]; place_3c363836cf4e16666669a25da280a1865c2d2874 [label="d", shape=circle]; place_86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 -> place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 [label="t1" style="solid"]; - place_3c363836cf4e16666669a25da280a1865c2d2874 -> place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 [label="My custom transition label 3" style="solid" fontcolor="Grey" color="Red"]; + place_3c363836cf4e16666669a25da280a1865c2d2874 -> place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 [label="My custom transition +label 3" style="solid" fontcolor="Grey" color="Red"]; place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 -> place_84a516841ba77a5b4648de2cd0dfcb30ea46dbb4 [label="t2" style="solid" color="Blue"]; place_e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 -> place_3c363836cf4e16666669a25da280a1865c2d2874 [label="t3" style="solid"]; } EOGRAPH; - $dump = $this->dumper->dump($definition, $marking); + $dump = (new StateMachineGraphvizDumper())->dump($definition, $marking); $this->assertEquals($expected, $dump); } diff --git a/Tests/Event/EventNameTraitTest.php b/Tests/Event/EventNameTraitTest.php new file mode 100644 index 0000000..3c74523 --- /dev/null +++ b/Tests/Event/EventNameTraitTest.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Tests\Event; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Workflow\Event\AnnounceEvent; +use Symfony\Component\Workflow\Event\CompletedEvent; +use Symfony\Component\Workflow\Event\EnteredEvent; +use Symfony\Component\Workflow\Event\EnterEvent; +use Symfony\Component\Workflow\Event\GuardEvent; +use Symfony\Component\Workflow\Event\LeaveEvent; +use Symfony\Component\Workflow\Event\TransitionEvent; + +class EventNameTraitTest extends TestCase +{ + /** + * @dataProvider getEvents + * + * @param class-string $class + */ + public function testEventNames(string $class, ?string $workflowName, ?string $transitionOrPlaceName, string $expected) + { + $name = $class::getName($workflowName, $transitionOrPlaceName); + $this->assertEquals($expected, $name); + } + + public static function getEvents(): iterable + { + yield [AnnounceEvent::class, null, null, 'workflow.announce']; + yield [AnnounceEvent::class, 'post', null, 'workflow.post.announce']; + yield [AnnounceEvent::class, 'post', 'publish', 'workflow.post.announce.publish']; + + yield [CompletedEvent::class, null, null, 'workflow.completed']; + yield [CompletedEvent::class, 'post', null, 'workflow.post.completed']; + yield [CompletedEvent::class, 'post', 'publish', 'workflow.post.completed.publish']; + + yield [EnteredEvent::class, null, null, 'workflow.entered']; + yield [EnteredEvent::class, 'post', null, 'workflow.post.entered']; + yield [EnteredEvent::class, 'post', 'published', 'workflow.post.entered.published']; + + yield [EnterEvent::class, null, null, 'workflow.enter']; + yield [EnterEvent::class, 'post', null, 'workflow.post.enter']; + yield [EnterEvent::class, 'post', 'published', 'workflow.post.enter.published']; + + yield [GuardEvent::class, null, null, 'workflow.guard']; + yield [GuardEvent::class, 'post', null, 'workflow.post.guard']; + yield [GuardEvent::class, 'post', 'publish', 'workflow.post.guard.publish']; + + yield [LeaveEvent::class, null, null, 'workflow.leave']; + yield [LeaveEvent::class, 'post', null, 'workflow.post.leave']; + yield [LeaveEvent::class, 'post', 'published', 'workflow.post.leave.published']; + + yield [TransitionEvent::class, null, null, 'workflow.transition']; + yield [TransitionEvent::class, 'post', null, 'workflow.post.transition']; + yield [TransitionEvent::class, 'post', 'publish', 'workflow.post.transition.publish']; + } + + public function testInvalidArgumentExceptionIsThrownIfWorkflowNameIsMissing() + { + $this->expectException(\InvalidArgumentException::class); + + EnterEvent::getName(null, 'place'); + } +} diff --git a/Tests/EventListener/AuditTrailListenerTest.php b/Tests/EventListener/AuditTrailListenerTest.php index 0416e7a..53336ba 100644 --- a/Tests/EventListener/AuditTrailListenerTest.php +++ b/Tests/EventListener/AuditTrailListenerTest.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests\EventListener; use PHPUnit\Framework\TestCase; @@ -42,7 +51,7 @@ public function testItWorks() class Logger extends AbstractLogger { - public $logs = []; + public array $logs = []; public function log($level, $message, array $context = []): void { diff --git a/Tests/EventListener/GuardListenerTest.php b/Tests/EventListener/GuardListenerTest.php index 713cd45..9880b85 100644 --- a/Tests/EventListener/GuardListenerTest.php +++ b/Tests/EventListener/GuardListenerTest.php @@ -1,13 +1,24 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests\EventListener; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Role\RoleHierarchy; +use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\ConstraintViolationList; use Symfony\Component\Validator\Validator\ValidatorInterface; @@ -22,10 +33,10 @@ class GuardListenerTest extends TestCase { - private $authenticationChecker; - private $validator; - private $listener; - private $configuration; + private MockObject&AuthorizationCheckerInterface $authenticationChecker; + private MockObject&ValidatorInterface $validator; + private GuardListener $listener; + private array $configuration; protected function setUp(): void { @@ -38,23 +49,16 @@ protected function setUp(): void ], ]; $expressionLanguage = new ExpressionLanguage(); - $token = new UsernamePasswordToken('username', 'credentials', 'provider', ['ROLE_USER']); - $tokenStorage = $this->getMockBuilder(TokenStorageInterface::class)->getMock(); + $token = new UsernamePasswordToken(new InMemoryUser('username', 'credentials', ['ROLE_USER']), 'provider', ['ROLE_USER']); + $tokenStorage = $this->createMock(TokenStorageInterface::class); $tokenStorage->expects($this->any())->method('getToken')->willReturn($token); - $this->authenticationChecker = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock(); - $trustResolver = $this->getMockBuilder(AuthenticationTrustResolverInterface::class)->getMock(); - $this->validator = $this->getMockBuilder(ValidatorInterface::class)->getMock(); + $this->authenticationChecker = $this->createMock(AuthorizationCheckerInterface::class); + $trustResolver = $this->createMock(AuthenticationTrustResolverInterface::class); + $this->validator = $this->createMock(ValidatorInterface::class); $roleHierarchy = new RoleHierarchy([]); $this->listener = new GuardListener($this->configuration, $expressionLanguage, $tokenStorage, $this->authenticationChecker, $trustResolver, $roleHierarchy, $this->validator); } - protected function tearDown(): void - { - $this->authenticationChecker = null; - $this->validator = null; - $this->listener = null; - } - public function testWithNotSupportedEvent() { $event = $this->createEvent(); @@ -133,12 +137,12 @@ public function testGuardExpressionBlocks() $this->assertTrue($event->isBlocked()); } - private function createEvent(Transition $transition = null) + private function createEvent(?Transition $transition = null): GuardEvent { $subject = new Subject(); - $transition = $transition ?: new Transition('name', 'from', 'to'); + $transition ??= new Transition('name', 'from', 'to'); - $workflow = $this->getMockBuilder(WorkflowInterface::class)->getMock(); + $workflow = $this->createMock(WorkflowInterface::class); return new GuardEvent($subject, new Marking($subject->getMarking() ?? []), $transition, $workflow); } @@ -161,7 +165,7 @@ private function configureAuthenticationChecker($isUsed, $granted = true) ; } - private function configureValidator($isUsed, $valid = true) + private function configureValidator($isUsed, $valid = true): void { if (!$isUsed) { $this->validator diff --git a/Tests/fixtures/puml/arrow/complex-state-machine-marking.puml b/Tests/Fixtures/puml/arrow/complex-state-machine-marking.puml similarity index 81% rename from Tests/fixtures/puml/arrow/complex-state-machine-marking.puml rename to Tests/Fixtures/puml/arrow/complex-state-machine-marking.puml index 59f8309..7f1367f 100644 --- a/Tests/fixtures/puml/arrow/complex-state-machine-marking.puml +++ b/Tests/Fixtures/puml/arrow/complex-state-machine-marking.puml @@ -15,7 +15,7 @@ state "b" state "c" <> state "d" "a" --> "b": "t1" -"d" -[#Red]-> "b": "My custom transition label 3" +"d" -[#Red]-> "b": "My custom transition\nlabel 3" "b" -[#Blue]-> "c": "t2" "b" --> "d": "t3" @enduml diff --git a/Tests/fixtures/puml/arrow/complex-state-machine-nomarking.puml b/Tests/Fixtures/puml/arrow/complex-state-machine-nomarking.puml similarity index 80% rename from Tests/fixtures/puml/arrow/complex-state-machine-nomarking.puml rename to Tests/Fixtures/puml/arrow/complex-state-machine-nomarking.puml index f3549c6..9d65314 100644 --- a/Tests/fixtures/puml/arrow/complex-state-machine-nomarking.puml +++ b/Tests/Fixtures/puml/arrow/complex-state-machine-nomarking.puml @@ -15,7 +15,7 @@ state "b" state "c" state "d" "a" --> "b": "t1" -"d" -[#Red]-> "b": "My custom transition label 3" +"d" -[#Red]-> "b": "My custom transition\nlabel 3" "b" -[#Blue]-> "c": "t2" "b" --> "d": "t3" @enduml diff --git a/Tests/fixtures/puml/square/complex-workflow-marking.puml b/Tests/Fixtures/puml/square/complex-workflow-marking.puml similarity index 100% rename from Tests/fixtures/puml/square/complex-workflow-marking.puml rename to Tests/Fixtures/puml/square/complex-workflow-marking.puml diff --git a/Tests/fixtures/puml/square/complex-workflow-nomarking.puml b/Tests/Fixtures/puml/square/complex-workflow-nomarking.puml similarity index 100% rename from Tests/fixtures/puml/square/complex-workflow-nomarking.puml rename to Tests/Fixtures/puml/square/complex-workflow-nomarking.puml diff --git a/Tests/fixtures/puml/square/simple-workflow-marking.puml b/Tests/Fixtures/puml/square/simple-workflow-marking.puml similarity index 91% rename from Tests/fixtures/puml/square/simple-workflow-marking.puml rename to Tests/Fixtures/puml/square/simple-workflow-marking.puml index 0ea138f..1e8a2ea 100644 --- a/Tests/fixtures/puml/square/simple-workflow-marking.puml +++ b/Tests/Fixtures/puml/square/simple-workflow-marking.puml @@ -17,8 +17,8 @@ skinparam agent { } state "a" <> state "b" <> -state "c" <> as c -c : My custom place description +state "c" <> +"c" : My custom place description agent "t1" agent "t2" "a" -[#Purple]-> "t1": "My custom transition label 2" diff --git a/Tests/fixtures/puml/square/simple-workflow-nomarking.puml b/Tests/Fixtures/puml/square/simple-workflow-nomarking.puml similarity index 91% rename from Tests/fixtures/puml/square/simple-workflow-nomarking.puml rename to Tests/Fixtures/puml/square/simple-workflow-nomarking.puml index 02e7f39..b57dc5b 100644 --- a/Tests/fixtures/puml/square/simple-workflow-nomarking.puml +++ b/Tests/Fixtures/puml/square/simple-workflow-nomarking.puml @@ -17,8 +17,8 @@ skinparam agent { } state "a" <> state "b" -state "c" <> as c -c : My custom place description +state "c" <> +"c" : My custom place description agent "t1" agent "t2" "a" -[#Purple]-> "t1": "My custom transition label 2" diff --git a/Tests/Fixtures/puml/square/simple-workflow-with-spaces.puml b/Tests/Fixtures/puml/square/simple-workflow-with-spaces.puml new file mode 100644 index 0000000..a62e8e0 --- /dev/null +++ b/Tests/Fixtures/puml/square/simple-workflow-with-spaces.puml @@ -0,0 +1,24 @@ +@startuml +allow_mixing +title SimpleDiagram +skinparam titleBorderRoundCorner 15 +skinparam titleBorderThickness 2 +skinparam state { + BackgroundColor<> #87b741 + BackgroundColor<> #3887C6 + BorderColor #3887C6 + BorderColor<> Black + FontColor<> White +} +skinparam agent { + BackgroundColor #ffffff + BorderColor #3887C6 +} +state "place a" <> +"place a" : My custom +"place a" : place description +state "place b" +agent "t 1" +"place a" --> "t 1" +"t 1" --> "place b" +@enduml diff --git a/Tests/MarkingStore/MethodMarkingStoreTest.php b/Tests/MarkingStore/MethodMarkingStoreTest.php index 155f285..af0be68 100644 --- a/Tests/MarkingStore/MethodMarkingStoreTest.php +++ b/Tests/MarkingStore/MethodMarkingStoreTest.php @@ -1,9 +1,17 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests\MarkingStore; use PHPUnit\Framework\TestCase; -use Symfony\Component\Workflow\Marking; use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore; use Symfony\Component\Workflow\Tests\Subject; @@ -17,14 +25,14 @@ public function testGetSetMarkingWithMultipleState() $marking = $markingStore->getMarking($subject); - $this->assertInstanceOf(Marking::class, $marking); $this->assertCount(0, $marking->getPlaces()); $marking->mark('first_place'); - $markingStore->setMarking($subject, $marking); + $markingStore->setMarking($subject, $marking, ['foo' => 'bar']); $this->assertSame(['first_place' => 1], $subject->getMarking()); + $this->assertSame(['foo' => 'bar'], $subject->getContext()); $marking2 = $markingStore->getMarking($subject); @@ -39,16 +47,16 @@ public function testGetSetMarkingWithSingleState() $marking = $markingStore->getMarking($subject); - $this->assertInstanceOf(Marking::class, $marking); $this->assertCount(0, $marking->getPlaces()); $marking->mark('first_place'); - $markingStore->setMarking($subject, $marking); + $markingStore->setMarking($subject, $marking, ['foo' => 'bar']); $this->assertSame('first_place', $subject->getMarking()); $marking2 = $markingStore->getMarking($subject); + $this->assertSame(['foo' => 'bar'], $subject->getContext()); $this->assertEquals($marking, $marking2); } @@ -61,7 +69,6 @@ public function testGetSetMarkingWithSingleStateAndAlmostEmptyPlaceName() $marking = $markingStore->getMarking($subject); - $this->assertInstanceOf(Marking::class, $marking); $this->assertCount(1, $marking->getPlaces()); } @@ -73,23 +80,44 @@ public function testGetMarkingWithValueObject() $marking = $markingStore->getMarking($subject); - $this->assertInstanceOf(Marking::class, $marking); $this->assertCount(1, $marking->getPlaces()); $this->assertSame('first_place', (string) $subject->getMarking()); } - private function createValueObject(string $markingValue) + public function testGetMarkingWithUninitializedProperty() + { + $subject = new SubjectWithType(); + + $markingStore = new MethodMarkingStore(true); + + $marking = $markingStore->getMarking($subject); + + $this->assertCount(0, $marking->getPlaces()); + } + + public function testGetMarkingWithUninitializedProperty2() + { + $subject = new SubjectWithType(); + + $markingStore = new MethodMarkingStore(true, 'marking2'); + + $this->expectException(\Error::class); + $this->expectExceptionMessage('Typed property Symfony\Component\Workflow\Tests\MarkingStore\SubjectWithType::$marking must not be accessed before initialization'); + + $markingStore->getMarking($subject); + } + + private function createValueObject(string $markingValue): object { return new class($markingValue) { - /** @var string */ - private $markingValue; + private string $markingValue; public function __construct(string $markingValue) { $this->markingValue = $markingValue; } - public function __toString() + public function __toString(): string { return $this->markingValue; } diff --git a/Tests/MarkingStore/PropertiesMarkingStoreTest.php b/Tests/MarkingStore/PropertiesMarkingStoreTest.php new file mode 100644 index 0000000..10548e5 --- /dev/null +++ b/Tests/MarkingStore/PropertiesMarkingStoreTest.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Tests\MarkingStore; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore; + +class PropertiesMarkingStoreTest extends TestCase +{ + public function testGetSetMarkingWithMultipleState() + { + $subject = new SubjectWithProperties(); + $markingStore = new MethodMarkingStore(false); + + $marking = $markingStore->getMarking($subject); + + $this->assertCount(0, $marking->getPlaces()); + + $marking->mark('first_place'); + + $markingStore->setMarking($subject, $marking, ['foo' => 'bar']); + + $this->assertSame(['first_place' => 1], $subject->marking); + + $marking2 = $markingStore->getMarking($subject); + + $this->assertEquals($marking, $marking2); + } + + public function testGetSetMarkingWithSingleState() + { + $subject = new SubjectWithProperties(); + $markingStore = new MethodMarkingStore(true, 'place', 'placeContext'); + + $marking = $markingStore->getMarking($subject); + + $this->assertCount(0, $marking->getPlaces()); + + $marking->mark('first_place'); + + $markingStore->setMarking($subject, $marking, ['foo' => 'bar']); + + $this->assertSame('first_place', $subject->place); + + $marking2 = $markingStore->getMarking($subject); + + $this->assertEquals($marking, $marking2); + } + + public function testGetSetMarkingWithSingleStateAndAlmostEmptyPlaceName() + { + $subject = new SubjectWithProperties(); + $subject->place = 0; + + $markingStore = new MethodMarkingStore(true, 'place'); + + $marking = $markingStore->getMarking($subject); + + $this->assertCount(1, $marking->getPlaces()); + } + + public function testGetMarkingWithValueObject() + { + $subject = new SubjectWithProperties(); + $subject->place = $this->createValueObject('first_place'); + + $markingStore = new MethodMarkingStore(true, 'place'); + + $marking = $markingStore->getMarking($subject); + + $this->assertCount(1, $marking->getPlaces()); + $this->assertSame('first_place', (string) $subject->place); + } + + public function testGetMarkingWithUninitializedProperty() + { + $subject = new SubjectWithProperties(); + + $markingStore = new MethodMarkingStore(true, 'place'); + + $marking = $markingStore->getMarking($subject); + + $this->assertCount(0, $marking->getPlaces()); + } + + private function createValueObject(string $markingValue): object + { + return new class($markingValue) { + public function __construct( + private string $markingValue, + ) { + } + + public function __toString(): string + { + return $this->markingValue; + } + }; + } +} diff --git a/Tests/MarkingStore/SubjectWithProperties.php b/Tests/MarkingStore/SubjectWithProperties.php new file mode 100644 index 0000000..7759448 --- /dev/null +++ b/Tests/MarkingStore/SubjectWithProperties.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Tests\MarkingStore; + +final class SubjectWithProperties +{ + // for type=workflow + public array $marking; + + // for type=state_machine + public string $place; + + private function getMarking(): array + { + return $this->marking; + } +} diff --git a/Tests/MarkingStore/SubjectWithType.php b/Tests/MarkingStore/SubjectWithType.php new file mode 100644 index 0000000..1040dab --- /dev/null +++ b/Tests/MarkingStore/SubjectWithType.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Tests\MarkingStore; + +class SubjectWithType +{ + private string $marking; + + public function getMarking(): string + { + return $this->marking; + } + + public function setMarking(string $type): void + { + $this->marking = $type; + } + + public function getMarking2(): string + { + // Typo made on purpose! + return $this->marking; + } +} diff --git a/Tests/MarkingTest.php b/Tests/MarkingTest.php index 9ed6df0..86a306a 100644 --- a/Tests/MarkingTest.php +++ b/Tests/MarkingTest.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests; use PHPUnit\Framework\TestCase; @@ -13,24 +22,70 @@ public function testMarking() $this->assertTrue($marking->has('a')); $this->assertFalse($marking->has('b')); - $this->assertSame(['a' => 1], $marking->getPlaces()); + $this->assertPlaces(['a' => 1], $marking); $marking->mark('b'); $this->assertTrue($marking->has('a')); $this->assertTrue($marking->has('b')); - $this->assertSame(['a' => 1, 'b' => 1], $marking->getPlaces()); + $this->assertPlaces(['a' => 1, 'b' => 1], $marking); $marking->unmark('a'); $this->assertFalse($marking->has('a')); $this->assertTrue($marking->has('b')); - $this->assertSame(['b' => 1], $marking->getPlaces()); + $this->assertPlaces(['b' => 1], $marking); $marking->unmark('b'); $this->assertFalse($marking->has('a')); $this->assertFalse($marking->has('b')); - $this->assertSame([], $marking->getPlaces()); + $this->assertPlaces([], $marking); + + $marking->mark('a'); + $this->assertPlaces(['a' => 1], $marking); + + $marking->mark('a'); + $this->assertPlaces(['a' => 2], $marking); + + $marking->unmark('a'); + $this->assertPlaces(['a' => 1], $marking); + + $marking->unmark('a'); + $this->assertPlaces([], $marking); + } + + public function testGuardNotMarked() + { + $marking = new Marking([]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The place "a" is not marked.'); + $marking->unmark('a'); + } + + public function testUnmarkGuardResultTokenCountIsNotNegative() + { + $marking = new Marking(['a' => 1]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The place "a" could not contain a negative token number: "1" (initial) - "2" (nbToken) = "-1".'); + $marking->unmark('a', 2); + } + + public function testUnmarkGuardNbTokenIsGreaterThanZero() + { + $marking = new Marking(['a' => 1]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The number of tokens must be greater than 0, "0" given.'); + $marking->unmark('a', 0); + } + + private function assertPlaces(array $expected, Marking $marking) + { + $places = $marking->getPlaces(); + ksort($places); + $this->assertSame($expected, $places); } } diff --git a/Tests/Metadata/InMemoryMetadataStoreTest.php b/Tests/Metadata/InMemoryMetadataStoreTest.php index ba54bcd..8177579 100644 --- a/Tests/Metadata/InMemoryMetadataStoreTest.php +++ b/Tests/Metadata/InMemoryMetadataStoreTest.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests\Metadata; use PHPUnit\Framework\TestCase; @@ -11,8 +20,8 @@ */ class InMemoryMetadataStoreTest extends TestCase { - private $store; - private $transition; + private InMemoryMetadataStore $store; + private Transition $transition; protected function setUp(): void { @@ -74,11 +83,4 @@ public function testGetMetadata() $this->assertNull($this->store->getMetadata('description', $this->transition)); $this->assertNull($this->store->getMetadata('description', new Transition('transition_2', [], []))); } - - public function testGetMetadataWithUnknownType() - { - $this->expectException('Symfony\Component\Workflow\Exception\InvalidArgumentException'); - $this->expectExceptionMessage('Could not find a MetadataBag for the subject of type "bool".'); - $this->store->getMetadata('title', true); - } } diff --git a/Tests/RegistryTest.php b/Tests/RegistryTest.php index 701b745..d3282a8 100644 --- a/Tests/RegistryTest.php +++ b/Tests/RegistryTest.php @@ -1,9 +1,20 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Workflow\Definition; +use Symfony\Component\Workflow\Exception\InvalidArgumentException; use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface; use Symfony\Component\Workflow\Registry; use Symfony\Component\Workflow\SupportStrategy\WorkflowSupportStrategyInterface; @@ -12,20 +23,15 @@ class RegistryTest extends TestCase { - private $registry; + private Registry $registry; protected function setUp(): void { $this->registry = new Registry(); - $this->registry->addWorkflow(new Workflow(new Definition([], []), $this->getMockBuilder(MarkingStoreInterface::class)->getMock(), $this->getMockBuilder(EventDispatcherInterface::class)->getMock(), 'workflow1'), $this->createWorkflowSupportStrategy(Subject1::class)); - $this->registry->addWorkflow(new Workflow(new Definition([], []), $this->getMockBuilder(MarkingStoreInterface::class)->getMock(), $this->getMockBuilder(EventDispatcherInterface::class)->getMock(), 'workflow2'), $this->createWorkflowSupportStrategy(Subject2::class)); - $this->registry->addWorkflow(new Workflow(new Definition([], []), $this->getMockBuilder(MarkingStoreInterface::class)->getMock(), $this->getMockBuilder(EventDispatcherInterface::class)->getMock(), 'workflow3'), $this->createWorkflowSupportStrategy(Subject2::class)); - } - - protected function tearDown(): void - { - $this->registry = null; + $this->registry->addWorkflow(new Workflow(new Definition([], []), $this->createMock(MarkingStoreInterface::class), $this->createMock(EventDispatcherInterface::class), 'workflow1'), $this->createWorkflowSupportStrategy(Subject1::class)); + $this->registry->addWorkflow(new Workflow(new Definition([], []), $this->createMock(MarkingStoreInterface::class), $this->createMock(EventDispatcherInterface::class), 'workflow2'), $this->createWorkflowSupportStrategy(Subject2::class)); + $this->registry->addWorkflow(new Workflow(new Definition([], []), $this->createMock(MarkingStoreInterface::class), $this->createMock(EventDispatcherInterface::class), 'workflow3'), $this->createWorkflowSupportStrategy(Subject2::class)); } public function testHasWithMatch() @@ -55,20 +61,16 @@ public function testGetWithSuccess() public function testGetWithMultipleMatch() { - $this->expectException('Symfony\Component\Workflow\Exception\InvalidArgumentException'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Too many workflows (workflow2, workflow3) match this subject (Symfony\Component\Workflow\Tests\Subject2); set a different name on each and use the second (name) argument of this method.'); - $w1 = $this->registry->get(new Subject2()); - $this->assertInstanceOf(Workflow::class, $w1); - $this->assertSame('workflow1', $w1->getName()); + $this->registry->get(new Subject2()); } public function testGetWithNoMatch() { - $this->expectException('Symfony\Component\Workflow\Exception\InvalidArgumentException'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Unable to find a workflow for class "stdClass".'); - $w1 = $this->registry->get(new \stdClass()); - $this->assertInstanceOf(Workflow::class, $w1); - $this->assertSame('workflow1', $w1->getName()); + $this->registry->get(new \stdClass()); } public function testAllWithOneMatchWithSuccess() @@ -98,13 +100,11 @@ public function testAllWithNoMatch() $this->assertCount(0, $workflows); } - private function createWorkflowSupportStrategy($supportedClassName) + private function createWorkflowSupportStrategy($supportedClassName): MockObject&WorkflowSupportStrategyInterface { - $strategy = $this->getMockBuilder(WorkflowSupportStrategyInterface::class)->getMock(); + $strategy = $this->createMock(WorkflowSupportStrategyInterface::class); $strategy->expects($this->any())->method('supports') - ->willReturnCallback(function ($workflow, $subject) use ($supportedClassName) { - return $subject instanceof $supportedClassName; - }); + ->willReturnCallback(fn ($workflow, $subject) => $subject instanceof $supportedClassName); return $strategy; } diff --git a/Tests/StateMachineTest.php b/Tests/StateMachineTest.php index a6c7362..5d10fde 100644 --- a/Tests/StateMachineTest.php +++ b/Tests/StateMachineTest.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests; use PHPUnit\Framework\TestCase; @@ -79,7 +88,7 @@ public function testBuildTransitionBlockerListReturnsExpectedReasonOnBranchMerge $net = new StateMachine($definition, null, $dispatcher); $dispatcher->addListener('workflow.guard', function (GuardEvent $event) { - $event->addTransitionBlocker(new TransitionBlocker(sprintf('Transition blocker of place %s', $event->getTransition()->getFroms()[0]), 'blocker')); + $event->addTransitionBlocker(new TransitionBlocker(\sprintf('Transition blocker of place %s', $event->getTransition()->getFroms()[0]), 'blocker')); }); $subject = new Subject(); @@ -115,7 +124,7 @@ public function testApplyReturnsExpectedReasonOnBranchMerge() $net = new StateMachine($definition, null, $dispatcher); $dispatcher->addListener('workflow.guard', function (GuardEvent $event) { - $event->addTransitionBlocker(new TransitionBlocker(sprintf('Transition blocker of place %s', $event->getTransition()->getFroms()[0]), 'blocker')); + $event->addTransitionBlocker(new TransitionBlocker(\sprintf('Transition blocker of place %s', $event->getTransition()->getFroms()[0]), 'blocker')); }); $subject = new Subject(); diff --git a/Tests/Subject.php b/Tests/Subject.php index 944cb22..d68d430 100644 --- a/Tests/Subject.php +++ b/Tests/Subject.php @@ -1,24 +1,32 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests; final class Subject { - private $marking; - private $context; + private string|array|null $marking; + private array $context = []; public function __construct($marking = null) { $this->marking = $marking; - $this->context = []; } - public function getMarking() + public function getMarking(): string|array|null { return $this->marking; } - public function setMarking($marking, array $context = []) + public function setMarking($marking, array $context = []): void { $this->marking = $marking; $this->context = $context; diff --git a/Tests/SupportStrategy/InstanceOfSupportStrategyTest.php b/Tests/SupportStrategy/InstanceOfSupportStrategyTest.php index a541da0..48a455e 100644 --- a/Tests/SupportStrategy/InstanceOfSupportStrategyTest.php +++ b/Tests/SupportStrategy/InstanceOfSupportStrategyTest.php @@ -1,7 +1,17 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests\SupportStrategy; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Workflow\SupportStrategy\InstanceOfSupportStrategy; use Symfony\Component\Workflow\Workflow; @@ -22,7 +32,7 @@ public function testSupportsIfNotClassInstance() $this->assertFalse($strategy->supports($this->createWorkflow(), new Subject1())); } - private function createWorkflow() + private function createWorkflow(): MockObject&Workflow { return $this->getMockBuilder(Workflow::class) ->disableOriginalConstructor() diff --git a/Tests/TransitionTest.php b/Tests/TransitionTest.php index 14a646d..aee5147 100644 --- a/Tests/TransitionTest.php +++ b/Tests/TransitionTest.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests; use PHPUnit\Framework\TestCase; diff --git a/Tests/Validator/StateMachineValidatorTest.php b/Tests/Validator/StateMachineValidatorTest.php index f3014e8..e88408b 100644 --- a/Tests/Validator/StateMachineValidatorTest.php +++ b/Tests/Validator/StateMachineValidatorTest.php @@ -1,9 +1,19 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests\Validator; use PHPUnit\Framework\TestCase; use Symfony\Component\Workflow\Definition; +use Symfony\Component\Workflow\Exception\InvalidDefinitionException; use Symfony\Component\Workflow\Transition; use Symfony\Component\Workflow\Validator\StateMachineValidator; @@ -11,7 +21,7 @@ class StateMachineValidatorTest extends TestCase { public function testWithMultipleTransitionWithSameNameShareInput() { - $this->expectException('Symfony\Component\Workflow\Exception\InvalidDefinitionException'); + $this->expectException(InvalidDefinitionException::class); $this->expectExceptionMessage('A transition from a place/state must have an unique name.'); $places = ['a', 'b', 'c']; $transitions[] = new Transition('t1', 'a', 'b'); @@ -35,7 +45,7 @@ public function testWithMultipleTransitionWithSameNameShareInput() public function testWithMultipleTos() { - $this->expectException('Symfony\Component\Workflow\Exception\InvalidDefinitionException'); + $this->expectException(InvalidDefinitionException::class); $this->expectExceptionMessage('A transition in StateMachine can only have one output.'); $places = ['a', 'b', 'c']; $transitions[] = new Transition('t1', 'a', ['b', 'c']); @@ -58,7 +68,7 @@ public function testWithMultipleTos() public function testWithMultipleFroms() { - $this->expectException('Symfony\Component\Workflow\Exception\InvalidDefinitionException'); + $this->expectException(InvalidDefinitionException::class); $this->expectExceptionMessage('A transition in StateMachine can only have one input.'); $places = ['a', 'b', 'c']; $transitions[] = new Transition('t1', ['a', 'b'], 'c'); @@ -106,27 +116,13 @@ public function testValid() public function testWithTooManyInitialPlaces() { - $this->expectException('Symfony\Component\Workflow\Exception\InvalidDefinitionException'); - $this->expectExceptionMessage('The state machine "foo" can not store many places. But the definition has 2 initial places. Only one is supported.'); $places = range('a', 'c'); $transitions = []; $definition = new Definition($places, $transitions, ['a', 'b']); - (new StateMachineValidator())->validate($definition, 'foo'); - - // the test ensures that the validation does not fail (i.e. it does not throw any exceptions) - $this->addToAssertionCount(1); + $this->expectException(InvalidDefinitionException::class); + $this->expectExceptionMessage('The state machine "foo" cannot store many places. But the definition has 2 initial places. Only one is supported.'); - // The graph looks like: - // - // +----+ +----+ +---+ - // | a | --> | t1 | --> | b | - // +----+ +----+ +---+ - // | - // | - // v - // +----+ +----+ - // | t2 | --> | c | - // +----+ +----+ + (new StateMachineValidator())->validate($definition, 'foo'); } } diff --git a/Tests/Validator/WorkflowValidatorTest.php b/Tests/Validator/WorkflowValidatorTest.php index 58f8ba7..50c3abd 100644 --- a/Tests/Validator/WorkflowValidatorTest.php +++ b/Tests/Validator/WorkflowValidatorTest.php @@ -1,9 +1,19 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests\Validator; use PHPUnit\Framework\TestCase; use Symfony\Component\Workflow\Definition; +use Symfony\Component\Workflow\Exception\InvalidDefinitionException; use Symfony\Component\Workflow\Tests\WorkflowBuilderTrait; use Symfony\Component\Workflow\Transition; use Symfony\Component\Workflow\Validator\WorkflowValidator; @@ -14,8 +24,6 @@ class WorkflowValidatorTest extends TestCase public function testWorkflowWithInvalidNames() { - $this->expectException('Symfony\Component\Workflow\Exception\InvalidDefinitionException'); - $this->expectExceptionMessage('All transitions for a place must have an unique name. Multiple transitions named "t1" where found for place "a" in workflow "foo".'); $places = range('a', 'c'); $transitions = []; @@ -25,6 +33,9 @@ public function testWorkflowWithInvalidNames() $definition = new Definition($places, $transitions); + $this->expectException(InvalidDefinitionException::class); + $this->expectExceptionMessage('All transitions for a place must have an unique name. Multiple transitions named "t1" where found for place "a" in workflow "foo".'); + (new WorkflowValidator())->validate($definition, 'foo'); } @@ -44,4 +55,32 @@ public function testSameTransitionNameButNotSamePlace() // the test ensures that the validation does not fail (i.e. it does not throw any exceptions) $this->addToAssertionCount(1); } + + public function testWithTooManyOutput() + { + $places = ['a', 'b', 'c']; + $transitions = [ + new Transition('t1', 'a', ['b', 'c']), + ]; + $definition = new Definition($places, $transitions); + + $this->expectException(InvalidDefinitionException::class); + $this->expectExceptionMessage('The marking store of workflow "foo" cannot store many places. But the transition "t1" has too many output (2). Only one is accepted.'); + + (new WorkflowValidator(true))->validate($definition, 'foo'); + } + + public function testWithTooManyInitialPlaces() + { + $places = ['a', 'b', 'c']; + $transitions = [ + new Transition('t1', 'a', 'b'), + ]; + $definition = new Definition($places, $transitions, ['a', 'b']); + + $this->expectException(InvalidDefinitionException::class); + $this->expectExceptionMessage('The marking store of workflow "foo" cannot store many places. But the definition has 2 initial places. Only one is supported.'); + + (new WorkflowValidator(true))->validate($definition, 'foo'); + } } diff --git a/Tests/WorkflowBuilderTrait.php b/Tests/WorkflowBuilderTrait.php index ae48d52..86478bb 100644 --- a/Tests/WorkflowBuilderTrait.php +++ b/Tests/WorkflowBuilderTrait.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests; use Symfony\Component\Workflow\Definition; @@ -8,7 +17,7 @@ trait WorkflowBuilderTrait { - private function createComplexWorkflowDefinition() + private static function createComplexWorkflowDefinition(): Definition { $places = range('a', 'g'); @@ -43,7 +52,7 @@ private function createComplexWorkflowDefinition() // +----+ +----+ +----+ +----+ } - private function createSimpleWorkflowDefinition() + private static function createSimpleWorkflowDefinition(): Definition { $places = range('a', 'c'); @@ -78,7 +87,7 @@ private function createSimpleWorkflowDefinition() // +---+ +----+ +---+ +----+ +---+ } - private function createWorkflowWithSameNameTransition() + private static function createWorkflowWithSameNameTransition(): Definition { $places = range('a', 'c'); @@ -106,7 +115,7 @@ private function createWorkflowWithSameNameTransition() // +--------------------------------------------------------------------+ } - private function createComplexStateMachineDefinition() + private static function createComplexStateMachineDefinition(): Definition { $places = ['a', 'b', 'c', 'd']; @@ -118,8 +127,13 @@ private function createComplexStateMachineDefinition() $transitions[] = new Transition('t3', 'b', 'd'); $transitionsMetadata = new \SplObjectStorage(); + // PHP 7.2 doesn't allow this heredoc syntax in an array, use a dedicated variable instead + $label = <<<'EOTXT' +My custom transition +label 3 +EOTXT; $transitionsMetadata[$transitionWithMetadataDumpStyle] = [ - 'label' => 'My custom transition label 3', + 'label' => $label, 'color' => 'Grey', 'arrow_color' => 'Red', ]; @@ -144,4 +158,43 @@ private function createComplexStateMachineDefinition() // | d | -------------+ // +-----+ } + + private static function createWorkflowWithSameNameBackTransition(): Definition + { + $places = range('a', 'c'); + + $transitions = []; + $transitions[] = new Transition('a_to_bc', 'a', ['b', 'c']); + $transitions[] = new Transition('back1', 'b', 'a'); + $transitions[] = new Transition('back1', 'c', 'b'); + $transitions[] = new Transition('back2', 'c', 'b'); + $transitions[] = new Transition('back2', 'b', 'a'); + $transitions[] = new Transition('c_to_cb', 'c', ['b', 'c']); + + return new Definition($places, $transitions); + + // The graph looks like: + // +-----------------------------------------------------------------+ + // | | + // | | + // | +---------------------------------------------+ | + // v | v | + // +---+ +---------+ +-------+ +---------+ +---+ +-------+ + // | a | --> | a_to_bc | --> | | --> | back2 | --> | | --> | back2 | + // +---+ +---------+ | | +---------+ | | +-------+ + // ^ | | | | + // | | c | <-----+ | b | + // | | | | | | + // | | | +---------+ | | +-------+ + // | | | --> | c_to_cb | --> | | --> | back1 | + // | +-------+ +---------+ +---+ +-------+ + // | | ^ | + // | | | | + // | v | | + // | +-------+ | | + // | | back1 | ----------------------+ | + // | +-------+ | + // | | + // +-----------------------------------------------------------------+ + } } diff --git a/Tests/WorkflowTest.php b/Tests/WorkflowTest.php index 1c6a3eb..48e2209 100644 --- a/Tests/WorkflowTest.php +++ b/Tests/WorkflowTest.php @@ -1,17 +1,27 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Workflow\Tests; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Workflow\Definition; +use Symfony\Component\Workflow\Event\EnteredEvent; use Symfony\Component\Workflow\Event\Event; use Symfony\Component\Workflow\Event\GuardEvent; use Symfony\Component\Workflow\Event\TransitionEvent; +use Symfony\Component\Workflow\Exception\LogicException; use Symfony\Component\Workflow\Exception\NotEnabledTransitionException; use Symfony\Component\Workflow\Exception\UndefinedTransitionException; use Symfony\Component\Workflow\Marking; -use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface; use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore; use Symfony\Component\Workflow\Transition; use Symfony\Component\Workflow\TransitionBlocker; @@ -22,19 +32,9 @@ class WorkflowTest extends TestCase { use WorkflowBuilderTrait; - public function testGetMarkingWithInvalidStoreReturn() - { - $this->expectException('Symfony\Component\Workflow\Exception\LogicException'); - $this->expectExceptionMessage('The value returned by the MarkingStore is not an instance of "Symfony\Component\Workflow\Marking" for workflow "unnamed".'); - $subject = new Subject(); - $workflow = new Workflow(new Definition([], []), $this->getMockBuilder(MarkingStoreInterface::class)->getMock()); - - $workflow->getMarking($subject); - } - public function testGetMarkingWithEmptyDefinition() { - $this->expectException('Symfony\Component\Workflow\Exception\LogicException'); + $this->expectException(LogicException::class); $this->expectExceptionMessage('The Marking is empty and there is no initial place for workflow "unnamed".'); $subject = new Subject(); $workflow = new Workflow(new Definition([], []), new MethodMarkingStore()); @@ -44,7 +44,7 @@ public function testGetMarkingWithEmptyDefinition() public function testGetMarkingWithImpossiblePlace() { - $this->expectException('Symfony\Component\Workflow\Exception\LogicException'); + $this->expectException(LogicException::class); $this->expectExceptionMessage('Place "nope" is not valid for workflow "unnamed".'); $subject = new Subject(); $subject->setMarking(['nope' => 1]); @@ -171,7 +171,7 @@ public function testCanWithSameNameTransition() public function testBuildTransitionBlockerListReturnsUndefinedTransition() { - $this->expectException('Symfony\Component\Workflow\Exception\UndefinedTransitionException'); + $this->expectException(UndefinedTransitionException::class); $this->expectExceptionMessage('Transition "404 Not Found" is not defined for workflow "unnamed".'); $definition = $this->createSimpleWorkflowDefinition(); $subject = new Subject(); @@ -287,7 +287,7 @@ public function testApplyWithNotEnabledTransition() $this->fail('Should throw an exception'); } catch (NotEnabledTransitionException $e) { - $this->assertSame('Transition "t2" is not enabled for workflow "unnamed".', $e->getMessage()); + $this->assertSame('Cannot apply transition "t2" on workflow "unnamed".', $e->getMessage()); $this->assertCount(1, $e->getTransitionBlockerList()); $list = iterator_to_array($e->getTransitionBlockerList()); $this->assertSame('The marking does not enable the transition.', $list[0]->getMessage()); @@ -320,28 +320,32 @@ public function testApplyWithSameNameTransition() $marking = $workflow->apply($subject, 'a_to_bc'); - $this->assertFalse($marking->has('a')); - $this->assertTrue($marking->has('b')); - $this->assertTrue($marking->has('c')); + $this->assertPlaces([ + 'b' => 1, + 'c' => 1, + ], $marking); $marking = $workflow->apply($subject, 'to_a'); - $this->assertTrue($marking->has('a')); - $this->assertFalse($marking->has('b')); - $this->assertFalse($marking->has('c')); + // Two tokens in "a" + $this->assertPlaces([ + 'a' => 2, + ], $marking); $workflow->apply($subject, 'a_to_bc'); $marking = $workflow->apply($subject, 'b_to_c'); - $this->assertFalse($marking->has('a')); - $this->assertFalse($marking->has('b')); - $this->assertTrue($marking->has('c')); + $this->assertPlaces([ + 'a' => 1, + 'c' => 2, + ], $marking); $marking = $workflow->apply($subject, 'to_a'); - $this->assertTrue($marking->has('a')); - $this->assertFalse($marking->has('b')); - $this->assertFalse($marking->has('c')); + $this->assertPlaces([ + 'a' => 2, + 'c' => 1, + ], $marking); } public function testApplyWithSameNameTransition2() @@ -428,14 +432,16 @@ public function testApplyWithEventDispatcher() $this->assertSame($eventNameExpected, $eventDispatcher->dispatchedEvents); } - public function provideApplyWithEventDispatcherForAnnounceTests() + public static function provideApplyWithEventDispatcherForAnnounceTests(): \Generator { yield [false, [Workflow::DISABLE_ANNOUNCE_EVENT => true]]; yield [true, [Workflow::DISABLE_ANNOUNCE_EVENT => false]]; yield [true, []]; } - /** @dataProvider provideApplyWithEventDispatcherForAnnounceTests */ + /** + * @dataProvider provideApplyWithEventDispatcherForAnnounceTests + */ public function testApplyWithEventDispatcherForAnnounce(bool $fired, array $context) { $definition = $this->createComplexWorkflowDefinition(); @@ -636,7 +642,28 @@ public function testEventContext() $dispatcher->addListener($eventName, $assertWorkflowContext); } - $workflow->apply($subject, 't1', $context); + $marking = $workflow->apply($subject, 't1', $context); + + $this->assertInstanceOf(Marking::class, $marking); + $this->assertSame($context, $marking->getContext()); + } + + public function testEventContextUpdated() + { + $definition = $this->createComplexWorkflowDefinition(); + $subject = new Subject(); + $dispatcher = new EventDispatcher(); + + $workflow = new Workflow($definition, new MethodMarkingStore(), $dispatcher); + + $dispatcher->addListener('workflow.transition', function (TransitionEvent $event) { + $event->setContext(['foo' => 'bar']); + }); + + $marking = $workflow->apply($subject, 't1', ['initial']); + + $this->assertInstanceOf(Marking::class, $marking); + $this->assertSame(['foo' => 'bar'], $marking->getContext()); } public function testEventDefaultInitialContext() @@ -663,6 +690,44 @@ public function testEventDefaultInitialContext() $workflow->apply($subject, 't1'); } + public function testEventWhenAlreadyInThisPlace() + { + // ┌──────┐ ┌──────────────────────┐ ┌───┐ ┌─────────────┐ ┌───┐ + // │ init │ ──▶ │ from_init_to_a_and_b │ ──▶ │ B │ ──▶ │ from_b_to_c │ ──▶ │ C │ + // └──────┘ └──────────────────────┘ └───┘ └─────────────┘ └───┘ + // │ + // │ + // ▼ + // ┌───────────────────────────────┐ + // │ A │ + // └───────────────────────────────┘ + $definition = new Definition( + ['init', 'A', 'B', 'C'], + [ + new Transition('from_init_to_a_and_b', 'init', ['A', 'B']), + new Transition('from_b_to_c', 'B', 'C'), + ], + ); + + $subject = new Subject(); + $dispatcher = new EventDispatcher(); + $name = 'workflow_name'; + $workflow = new Workflow($definition, new MethodMarkingStore(), $dispatcher, $name); + + $calls = []; + $listener = function (Event $event) use (&$calls) { + $calls[] = $event; + }; + $dispatcher->addListener("workflow.$name.entered.A", $listener); + + $workflow->apply($subject, 'from_init_to_a_and_b'); + $workflow->apply($subject, 'from_b_to_c'); + + $this->assertCount(1, $calls); + $this->assertInstanceOf(EnteredEvent::class, $calls[0]); + $this->assertSame('from_init_to_a_and_b', $calls[0]->getTransition()->getName()); + } + public function testMarkingStateOnApplyWithEventDispatcher() { $definition = new Definition(range('a', 'f'), [new Transition('t', range('a', 'c'), range('d', 'f'))]); @@ -708,7 +773,7 @@ public function testGetEnabledTransitions() }); $workflow = new Workflow($definition, new MethodMarkingStore(), $eventDispatcher, 'workflow_name'); - $this->assertEmpty($workflow->getEnabledTransitions($subject)); + $this->assertSame([], $workflow->getEnabledTransitions($subject)); $subject->setMarking(['d' => 1]); $transitions = $workflow->getEnabledTransitions($subject); @@ -754,13 +819,70 @@ public function testGetEnabledTransitionsWithSameNameTransition() $this->assertSame('to_a', $transitions[1]->getName()); $this->assertSame('to_a', $transitions[2]->getName()); } + + /** + * @@testWith ["back1"] + * ["back2"] + */ + public function testApplyWithSameNameBackTransition(string $transition) + { + $definition = $this->createWorkflowWithSameNameBackTransition(); + $workflow = new Workflow($definition, new MethodMarkingStore()); + + $subject = new Subject(); + + $marking = $workflow->apply($subject, 'a_to_bc'); + $this->assertPlaces([ + 'b' => 1, + 'c' => 1, + ], $marking); + + $marking = $workflow->apply($subject, $transition); + $this->assertPlaces([ + 'a' => 1, + 'b' => 1, + ], $marking); + + $marking = $workflow->apply($subject, $transition); + $this->assertPlaces([ + 'a' => 2, + ], $marking); + + $marking = $workflow->apply($subject, 'a_to_bc'); + $this->assertPlaces([ + 'a' => 1, + 'b' => 1, + 'c' => 1, + ], $marking); + + $marking = $workflow->apply($subject, 'c_to_cb'); + $this->assertPlaces([ + 'a' => 1, + 'b' => 2, + 'c' => 1, + ], $marking); + + $marking = $workflow->apply($subject, 'c_to_cb'); + $this->assertPlaces([ + 'a' => 1, + 'b' => 3, + 'c' => 1, + ], $marking); + } + + private function assertPlaces(array $expected, Marking $marking) + { + $places = $marking->getPlaces(); + ksort($places); + $this->assertSame($expected, $places); + } } class EventDispatcherMock implements \Symfony\Contracts\EventDispatcher\EventDispatcherInterface { - public $dispatchedEvents = []; + public array $dispatchedEvents = []; - public function dispatch($event, string $eventName = null): object + public function dispatch($event, ?string $eventName = null): object { $this->dispatchedEvents[] = $eventName; diff --git a/Transition.php b/Transition.php index f5a19b1..05fe267 100644 --- a/Transition.php +++ b/Transition.php @@ -17,32 +17,39 @@ */ class Transition { - private $name; - private $froms; - private $tos; + private array $froms; + private array $tos; /** * @param string|string[] $froms * @param string|string[] $tos */ - public function __construct(string $name, $froms, $tos) - { - $this->name = $name; + public function __construct( + private string $name, + string|array $froms, + string|array $tos, + ) { $this->froms = (array) $froms; $this->tos = (array) $tos; } - public function getName() + public function getName(): string { return $this->name; } - public function getFroms() + /** + * @return string[] + */ + public function getFroms(): array { return $this->froms; } - public function getTos() + /** + * @return string[] + */ + public function getTos(): array { return $this->tos; } diff --git a/TransitionBlocker.php b/TransitionBlocker.php index 374286f..6a745a2 100644 --- a/TransitionBlocker.php +++ b/TransitionBlocker.php @@ -16,13 +16,9 @@ */ final class TransitionBlocker { - const BLOCKED_BY_MARKING = '19beefc8-6b1e-4716-9d07-a39bd6d16e34'; - const BLOCKED_BY_EXPRESSION_GUARD_LISTENER = '326a1e9c-0c12-11e8-ba89-0ed5f89f718b'; - const UNKNOWN = 'e8b5bbb9-5913-4b98-bfa6-65dbd228a82a'; - - private $message; - private $code; - private $parameters; + public const BLOCKED_BY_MARKING = '19beefc8-6b1e-4716-9d07-a39bd6d16e34'; + public const BLOCKED_BY_EXPRESSION_GUARD_LISTENER = '326a1e9c-0c12-11e8-ba89-0ed5f89f718b'; + public const UNKNOWN = 'e8b5bbb9-5913-4b98-bfa6-65dbd228a82a'; /** * @param string $code Code is a machine-readable string, usually an UUID @@ -30,11 +26,11 @@ final class TransitionBlocker * blocked the transition. E.g. for a condition "distance must be larger than * 5 miles", you might want to pass around the value of 5. */ - public function __construct(string $message, string $code, array $parameters = []) - { - $this->message = $message; - $this->code = $code; - $this->parameters = $parameters; + public function __construct( + private string $message, + private string $code, + private array $parameters = [], + ) { } /** @@ -67,13 +63,13 @@ public static function createBlockedByExpressionGuardListener(string $expression * Creates a blocker that says the transition cannot be made because of an * unknown reason. */ - public static function createUnknown(string $message = null, int $backtraceFrame = 2): self + public static function createUnknown(?string $message = null, int $backtraceFrame = 2): self { if (null !== $message) { return new static($message, self::UNKNOWN); } - $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, $backtraceFrame + 1)[$backtraceFrame]['class'] ?? null; + $caller = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, $backtraceFrame + 1)[$backtraceFrame]['class'] ?? null; if (null !== $caller) { return new static("The transition has been blocked by a guard ($caller).", self::UNKNOWN); diff --git a/TransitionBlockerList.php b/TransitionBlockerList.php index f7f4a63..d3a1af3 100644 --- a/TransitionBlockerList.php +++ b/TransitionBlockerList.php @@ -15,10 +15,12 @@ * A list of transition blockers. * * @author Grégoire Pineau + * + * @implements \IteratorAggregate */ final class TransitionBlockerList implements \IteratorAggregate, \Countable { - private $blockers; + private array $blockers; /** * @param TransitionBlocker[] $blockers @@ -58,11 +60,6 @@ public function isEmpty(): bool return !$this->blockers; } - /** - * {@inheritdoc} - * - * @return \ArrayIterator|TransitionBlocker[] - */ public function getIterator(): \Traversable { return new \ArrayIterator($this->blockers); diff --git a/Validator/DefinitionValidatorInterface.php b/Validator/DefinitionValidatorInterface.php index f02f582..7944a05 100644 --- a/Validator/DefinitionValidatorInterface.php +++ b/Validator/DefinitionValidatorInterface.php @@ -23,5 +23,5 @@ interface DefinitionValidatorInterface /** * @throws InvalidDefinitionException on invalid definition */ - public function validate(Definition $definition, string $name); + public function validate(Definition $definition, string $name): void; } diff --git a/Validator/StateMachineValidator.php b/Validator/StateMachineValidator.php index 355c760..626a20e 100644 --- a/Validator/StateMachineValidator.php +++ b/Validator/StateMachineValidator.php @@ -19,25 +19,25 @@ */ class StateMachineValidator implements DefinitionValidatorInterface { - public function validate(Definition $definition, string $name) + public function validate(Definition $definition, string $name): void { $transitionFromNames = []; foreach ($definition->getTransitions() as $transition) { // Make sure that each transition has exactly one TO if (1 !== \count($transition->getTos())) { - throw new InvalidDefinitionException(sprintf('A transition in StateMachine can only have one output. But the transition "%s" in StateMachine "%s" has %d outputs.', $transition->getName(), $name, \count($transition->getTos()))); + throw new InvalidDefinitionException(\sprintf('A transition in StateMachine can only have one output. But the transition "%s" in StateMachine "%s" has %d outputs.', $transition->getName(), $name, \count($transition->getTos()))); } // Make sure that each transition has exactly one FROM $froms = $transition->getFroms(); if (1 !== \count($froms)) { - throw new InvalidDefinitionException(sprintf('A transition in StateMachine can only have one input. But the transition "%s" in StateMachine "%s" has %d inputs.', $transition->getName(), $name, \count($froms))); + throw new InvalidDefinitionException(\sprintf('A transition in StateMachine can only have one input. But the transition "%s" in StateMachine "%s" has %d inputs.', $transition->getName(), $name, \count($froms))); } // Enforcing uniqueness of the names of transitions starting at each node $from = reset($froms); if (isset($transitionFromNames[$from][$transition->getName()])) { - throw new InvalidDefinitionException(sprintf('A transition from a place/state must have an unique name. Multiple transitions named "%s" from place/state "%s" were found on StateMachine "%s".', $transition->getName(), $from, $name)); + throw new InvalidDefinitionException(\sprintf('A transition from a place/state must have an unique name. Multiple transitions named "%s" from place/state "%s" were found on StateMachine "%s".', $transition->getName(), $from, $name)); } $transitionFromNames[$from][$transition->getName()] = true; @@ -45,7 +45,7 @@ public function validate(Definition $definition, string $name) $initialPlaces = $definition->getInitialPlaces(); if (2 <= \count($initialPlaces)) { - throw new InvalidDefinitionException(sprintf('The state machine "%s" can not store many places. But the definition has %d initial places. Only one is supported.', $name, \count($initialPlaces))); + throw new InvalidDefinitionException(\sprintf('The state machine "%s" cannot store many places. But the definition has %d initial places. Only one is supported.', $name, \count($initialPlaces))); } } } diff --git a/Validator/WorkflowValidator.php b/Validator/WorkflowValidator.php index f0e7402..f4eb292 100644 --- a/Validator/WorkflowValidator.php +++ b/Validator/WorkflowValidator.php @@ -20,21 +20,19 @@ */ class WorkflowValidator implements DefinitionValidatorInterface { - private $singlePlace; - - public function __construct(bool $singlePlace = false) - { - $this->singlePlace = $singlePlace; + public function __construct( + private bool $singlePlace = false, + ) { } - public function validate(Definition $definition, string $name) + public function validate(Definition $definition, string $name): void { // Make sure all transitions for one place has unique name. $places = array_fill_keys($definition->getPlaces(), []); foreach ($definition->getTransitions() as $transition) { foreach ($transition->getFroms() as $from) { - if (\in_array($transition->getName(), $places[$from])) { - throw new InvalidDefinitionException(sprintf('All transitions for a place must have an unique name. Multiple transitions named "%s" where found for place "%s" in workflow "%s".', $transition->getName(), $from, $name)); + if (\in_array($transition->getName(), $places[$from], true)) { + throw new InvalidDefinitionException(\sprintf('All transitions for a place must have an unique name. Multiple transitions named "%s" where found for place "%s" in workflow "%s".', $transition->getName(), $from, $name)); } $places[$from][] = $transition->getName(); } @@ -46,13 +44,13 @@ public function validate(Definition $definition, string $name) foreach ($definition->getTransitions() as $transition) { if (1 < \count($transition->getTos())) { - throw new InvalidDefinitionException(sprintf('The marking store of workflow "%s" can not store many places. But the transition "%s" has too many output (%d). Only one is accepted.', $name, $transition->getName(), \count($transition->getTos()))); + throw new InvalidDefinitionException(\sprintf('The marking store of workflow "%s" cannot store many places. But the transition "%s" has too many output (%d). Only one is accepted.', $name, $transition->getName(), \count($transition->getTos()))); } } $initialPlaces = $definition->getInitialPlaces(); if (2 <= \count($initialPlaces)) { - throw new InvalidDefinitionException(sprintf('The marking store of workflow "%s" can not store many places. But the definition has %d initial places. Only one is supported.', $name, \count($initialPlaces))); + throw new InvalidDefinitionException(\sprintf('The marking store of workflow "%s" cannot store many places. But the definition has %d initial places. Only one is supported.', $name, \count($initialPlaces))); } } } diff --git a/Workflow.php b/Workflow.php index 00abecd..9165ebb 100644 --- a/Workflow.php +++ b/Workflow.php @@ -52,45 +52,32 @@ class Workflow implements WorkflowInterface WorkflowEvents::ANNOUNCE => self::DISABLE_ANNOUNCE_EVENT, ]; - private $definition; - private $markingStore; - private $dispatcher; - private $name; + private MarkingStoreInterface $markingStore; /** - * When `null` fire all events (the default behaviour). - * Setting this to an empty array `[]` means no events are dispatched (except the Guard Event). - * Passing an array with WorkflowEvents will allow only those events to be dispatched plus - * the Guard Event. - * - * @var array|string[]|null + * @param array|string[]|null $eventsToDispatch When `null` fire all events (the default behaviour). + * Setting this to an empty array `[]` means no events are dispatched (except the {@see GuardEvent}). + * Passing an array with WorkflowEvents will allow only those events to be dispatched plus + * the {@see GuardEvent}. */ - private $eventsToDispatch = null; - - public function __construct(Definition $definition, MarkingStoreInterface $markingStore = null, EventDispatcherInterface $dispatcher = null, string $name = 'unnamed', array $eventsToDispatch = null) - { - $this->definition = $definition; - $this->markingStore = $markingStore ?: new MethodMarkingStore(); - $this->dispatcher = $dispatcher; - $this->name = $name; - $this->eventsToDispatch = $eventsToDispatch; + public function __construct( + private Definition $definition, + ?MarkingStoreInterface $markingStore = null, + private ?EventDispatcherInterface $dispatcher = null, + private string $name = 'unnamed', + private ?array $eventsToDispatch = null, + ) { + $this->markingStore = $markingStore ?? new MethodMarkingStore(); } - /** - * {@inheritdoc} - */ - public function getMarking(object $subject, array $context = []) + public function getMarking(object $subject, array $context = []): Marking { $marking = $this->markingStore->getMarking($subject); - if (!$marking instanceof Marking) { - throw new LogicException(sprintf('The value returned by the MarkingStore is not an instance of "%s" for workflow "%s".', Marking::class, $this->name)); - } - // check if the subject is already in the workflow if (!$marking->getPlaces()) { if (!$this->definition->getInitialPlaces()) { - throw new LogicException(sprintf('The Marking is empty and there is no initial place for workflow "%s".', $this->name)); + throw new LogicException(\sprintf('The Marking is empty and there is no initial place for workflow "%s".', $this->name)); } foreach ($this->definition->getInitialPlaces() as $place) { $marking->mark($place); @@ -110,7 +97,7 @@ public function getMarking(object $subject, array $context = []) $places = $this->definition->getPlaces(); foreach ($marking->getPlaces() as $placeName => $nbToken) { if (!isset($places[$placeName])) { - $message = sprintf('Place "%s" is not valid for workflow "%s".', $placeName, $this->name); + $message = \sprintf('Place "%s" is not valid for workflow "%s".', $placeName, $this->name); if (!$places) { $message .= ' It seems you forgot to add places to the current workflow.'; } @@ -122,10 +109,7 @@ public function getMarking(object $subject, array $context = []) return $marking; } - /** - * {@inheritdoc} - */ - public function can(object $subject, string $transitionName) + public function can(object $subject, string $transitionName): bool { $transitions = $this->definition->getTransitions(); $marking = $this->getMarking($subject); @@ -145,9 +129,6 @@ public function can(object $subject, string $transitionName) return false; } - /** - * {@inheritdoc} - */ public function buildTransitionBlockerList(object $subject, string $transitionName): TransitionBlockerList { $transitions = $this->definition->getTransitions(); @@ -181,10 +162,7 @@ public function buildTransitionBlockerList(object $subject, string $transitionNa return $transitionBlockerList; } - /** - * {@inheritdoc} - */ - public function apply(object $subject, string $transitionName, array $context = []) + public function apply(object $subject, string $transitionName, array $context = []): Marking { $marking = $this->getMarking($subject, $context); @@ -244,13 +222,12 @@ public function apply(object $subject, string $transitionName, array $context = $this->announce($subject, $transition, $marking, $context); } + $marking->setContext($context); + return $marking; } - /** - * {@inheritdoc} - */ - public function getEnabledTransitions(object $subject) + public function getEnabledTransitions(object $subject): array { $enabledTransitions = []; $marking = $this->getMarking($subject); @@ -284,33 +261,21 @@ public function getEnabledTransition(object $subject, string $name): ?Transition return null; } - /** - * {@inheritdoc} - */ - public function getName() + public function getName(): string { return $this->name; } - /** - * {@inheritdoc} - */ - public function getDefinition() + public function getDefinition(): Definition { return $this->definition; } - /** - * {@inheritdoc} - */ - public function getMarkingStore() + public function getMarkingStore(): MarkingStoreInterface { return $this->markingStore; } - /** - * {@inheritdoc} - */ public function getMetadataStore(): MetadataStoreInterface { return $this->definition->getMetadataStore(); @@ -348,8 +313,8 @@ private function guardTransition(object $subject, Marking $marking, Transition $ $event = new GuardEvent($subject, $marking, $transition, $this); $this->dispatcher->dispatch($event, WorkflowEvents::GUARD); - $this->dispatcher->dispatch($event, sprintf('workflow.%s.guard', $this->name)); - $this->dispatcher->dispatch($event, sprintf('workflow.%s.guard.%s', $this->name, $transition->getName())); + $this->dispatcher->dispatch($event, \sprintf('workflow.%s.guard', $this->name)); + $this->dispatcher->dispatch($event, \sprintf('workflow.%s.guard.%s', $this->name, $transition->getName())); return $event; } @@ -362,10 +327,10 @@ private function leave(object $subject, Transition $transition, Marking $marking $event = new LeaveEvent($subject, $marking, $transition, $this, $context); $this->dispatcher->dispatch($event, WorkflowEvents::LEAVE); - $this->dispatcher->dispatch($event, sprintf('workflow.%s.leave', $this->name)); + $this->dispatcher->dispatch($event, \sprintf('workflow.%s.leave', $this->name)); foreach ($places as $place) { - $this->dispatcher->dispatch($event, sprintf('workflow.%s.leave.%s', $this->name, $place)); + $this->dispatcher->dispatch($event, \sprintf('workflow.%s.leave.%s', $this->name, $place)); } } @@ -383,8 +348,8 @@ private function transition(object $subject, Transition $transition, Marking $ma $event = new TransitionEvent($subject, $marking, $transition, $this, $context); $this->dispatcher->dispatch($event, WorkflowEvents::TRANSITION); - $this->dispatcher->dispatch($event, sprintf('workflow.%s.transition', $this->name)); - $this->dispatcher->dispatch($event, sprintf('workflow.%s.transition.%s', $this->name, $transition->getName())); + $this->dispatcher->dispatch($event, \sprintf('workflow.%s.transition', $this->name)); + $this->dispatcher->dispatch($event, \sprintf('workflow.%s.transition.%s', $this->name, $transition->getName())); return $event->getContext(); } @@ -397,10 +362,10 @@ private function enter(object $subject, Transition $transition, Marking $marking $event = new EnterEvent($subject, $marking, $transition, $this, $context); $this->dispatcher->dispatch($event, WorkflowEvents::ENTER); - $this->dispatcher->dispatch($event, sprintf('workflow.%s.enter', $this->name)); + $this->dispatcher->dispatch($event, \sprintf('workflow.%s.enter', $this->name)); foreach ($places as $place) { - $this->dispatcher->dispatch($event, sprintf('workflow.%s.enter.%s', $this->name, $place)); + $this->dispatcher->dispatch($event, \sprintf('workflow.%s.enter.%s', $this->name, $place)); } } @@ -418,10 +383,16 @@ private function entered(object $subject, ?Transition $transition, Marking $mark $event = new EnteredEvent($subject, $marking, $transition, $this, $context); $this->dispatcher->dispatch($event, WorkflowEvents::ENTERED); - $this->dispatcher->dispatch($event, sprintf('workflow.%s.entered', $this->name)); + $this->dispatcher->dispatch($event, \sprintf('workflow.%s.entered', $this->name)); - foreach ($marking->getPlaces() as $placeName => $nbToken) { - $this->dispatcher->dispatch($event, sprintf('workflow.%s.entered.%s', $this->name, $placeName)); + $placeNames = []; + if ($transition) { + $placeNames = $transition->getTos(); + } elseif ($this->definition->getInitialPlaces()) { + $placeNames = $this->definition->getInitialPlaces(); + } + foreach ($placeNames as $placeName) { + $this->dispatcher->dispatch($event, \sprintf('workflow.%s.entered.%s', $this->name, $placeName)); } } @@ -434,8 +405,8 @@ private function completed(object $subject, Transition $transition, Marking $mar $event = new CompletedEvent($subject, $marking, $transition, $this, $context); $this->dispatcher->dispatch($event, WorkflowEvents::COMPLETED); - $this->dispatcher->dispatch($event, sprintf('workflow.%s.completed', $this->name)); - $this->dispatcher->dispatch($event, sprintf('workflow.%s.completed.%s', $this->name, $transition->getName())); + $this->dispatcher->dispatch($event, \sprintf('workflow.%s.completed', $this->name)); + $this->dispatcher->dispatch($event, \sprintf('workflow.%s.completed.%s', $this->name, $transition->getName())); } private function announce(object $subject, Transition $initialTransition, Marking $marking, array $context): void @@ -447,10 +418,10 @@ private function announce(object $subject, Transition $initialTransition, Markin $event = new AnnounceEvent($subject, $marking, $initialTransition, $this, $context); $this->dispatcher->dispatch($event, WorkflowEvents::ANNOUNCE); - $this->dispatcher->dispatch($event, sprintf('workflow.%s.announce', $this->name)); + $this->dispatcher->dispatch($event, \sprintf('workflow.%s.announce', $this->name)); foreach ($this->getEnabledTransitions($subject) as $transition) { - $this->dispatcher->dispatch($event, sprintf('workflow.%s.announce.%s', $this->name, $transition->getName())); + $this->dispatcher->dispatch($event, \sprintf('workflow.%s.announce.%s', $this->name, $transition->getName())); } } diff --git a/WorkflowEvents.php b/WorkflowEvents.php index 9b6db20..999c6d8 100644 --- a/WorkflowEvents.php +++ b/WorkflowEvents.php @@ -28,44 +28,44 @@ final class WorkflowEvents /** * @Event("Symfony\Component\Workflow\Event\GuardEvent") */ - const GUARD = 'workflow.guard'; + public const GUARD = 'workflow.guard'; /** * @Event("Symfony\Component\Workflow\Event\LeaveEvent") */ - const LEAVE = 'workflow.leave'; + public const LEAVE = 'workflow.leave'; /** * @Event("Symfony\Component\Workflow\Event\TransitionEvent") */ - const TRANSITION = 'workflow.transition'; + public const TRANSITION = 'workflow.transition'; /** * @Event("Symfony\Component\Workflow\Event\EnterEvent") */ - const ENTER = 'workflow.enter'; + public const ENTER = 'workflow.enter'; /** * @Event("Symfony\Component\Workflow\Event\EnteredEvent") */ - const ENTERED = 'workflow.entered'; + public const ENTERED = 'workflow.entered'; /** * @Event("Symfony\Component\Workflow\Event\CompletedEvent") */ - const COMPLETED = 'workflow.completed'; + public const COMPLETED = 'workflow.completed'; /** * @Event("Symfony\Component\Workflow\Event\AnnounceEvent") */ - const ANNOUNCE = 'workflow.announce'; + public const ANNOUNCE = 'workflow.announce'; /** * Event aliases. * * These aliases can be consumed by RegisterListenersPass. */ - const ALIASES = [ + public const ALIASES = [ GuardEvent::class => self::GUARD, LeaveEvent::class => self::LEAVE, TransitionEvent::class => self::TRANSITION, diff --git a/WorkflowInterface.php b/WorkflowInterface.php index 39d9361..6f5bff2 100644 --- a/WorkflowInterface.php +++ b/WorkflowInterface.php @@ -12,65 +12,57 @@ namespace Symfony\Component\Workflow; use Symfony\Component\Workflow\Exception\LogicException; +use Symfony\Component\Workflow\Exception\UndefinedTransitionException; use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface; use Symfony\Component\Workflow\Metadata\MetadataStoreInterface; /** + * Describes a workflow instance. + * * @author Amrouche Hamza + * + * @method Transition|null getEnabledTransition(object $subject, string $name) */ interface WorkflowInterface { /** * Returns the object's Marking. * - * @return Marking The Marking - * * @throws LogicException */ - public function getMarking(object $subject); + public function getMarking(object $subject): Marking; /** * Returns true if the transition is enabled. - * - * @return bool true if the transition is enabled */ - public function can(object $subject, string $transitionName); + public function can(object $subject, string $transitionName): bool; /** * Builds a TransitionBlockerList to know why a transition is blocked. + * + * @throws UndefinedTransitionException If the transition is not defined */ public function buildTransitionBlockerList(object $subject, string $transitionName): TransitionBlockerList; /** * Fire a transition. * - * @return Marking The new Marking - * * @throws LogicException If the transition is not applicable */ - public function apply(object $subject, string $transitionName, array $context = []); + public function apply(object $subject, string $transitionName, array $context = []): Marking; /** * Returns all enabled transitions. * - * @return Transition[] All enabled transitions + * @return Transition[] */ - public function getEnabledTransitions(object $subject); + public function getEnabledTransitions(object $subject): array; - /** - * @return string - */ - public function getName(); + public function getName(): string; - /** - * @return Definition - */ - public function getDefinition(); + public function getDefinition(): Definition; - /** - * @return MarkingStoreInterface - */ - public function getMarkingStore(); + public function getMarkingStore(): MarkingStoreInterface; public function getMetadataStore(): MetadataStoreInterface; } diff --git a/composer.json b/composer.json index 63dfab4..3e2c50a 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "symfony/workflow", "type": "library", - "description": "Symfony Workflow Component", + "description": "Provides tools for managing a workflow or finite state machine", "keywords": ["workflow", "petrinet", "place", "transition", "statemachine", "state"], "homepage": "https://symfony.com", "license": "MIT", @@ -20,27 +20,29 @@ } ], "require": { - "php": ">=7.2.5", - "symfony/polyfill-php80": "^1.15" + "php": ">=8.2", + "symfony/deprecation-contracts": "2.5|^3" }, "require-dev": { - "psr/log": "~1.0", - "symfony/dependency-injection": "^4.4|^5.0", - "symfony/event-dispatcher": "^4.4|^5.0", - "symfony/expression-language": "^4.4|^5.0", - "symfony/security-core": "^4.4|^5.0", - "symfony/validator": "^4.4|^5.0" + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/security-core": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0" }, "conflict": { - "symfony/event-dispatcher": "<4.4" + "symfony/event-dispatcher": "<6.4" }, "autoload": { - "psr-4": { "Symfony\\Component\\Workflow\\": "" } + "psr-4": { "Symfony\\Component\\Workflow\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] }, - "minimum-stability": "dev", - "extra": { - "branch-alias": { - "dev-master": "5.2-dev" - } - } + "minimum-stability": "dev" } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index cf444d5..15e5deb 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ - - + + ./ - - ./Tests - ./vendor - - - + + + ./Tests + ./vendor + + pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy