From ec6f7ef090b1c0a71321505f51f5d465d141587f Mon Sep 17 00:00:00 2001 From: Yohan Giarelli Date: Mon, 13 Jan 2025 21:08:38 +0100 Subject: [PATCH 1/2] feat(symfony): Added symfony bundle --- .dockerignore | 10 ++---- .gitignore | 10 ++---- Dockerfile | 18 +++++------ Makefile | 2 +- composer.json | 8 +++-- phpunit.xml.dist | 2 +- .../DependencyInjection/FiniteExtension.php | 23 ++++++++++++++ src/Extension/Symfony/Bundle/FiniteBundle.php | 10 ++++++ tests/Extension/Symfony/ServiceTest.php | 25 +++++++++++++++ .../Symfony/fixtures/app/AppKernel.php | 31 +++++++++++++++++++ .../Symfony/fixtures/app/bootstrap.php | 7 +++++ tests/bootstrap.php | 5 --- 12 files changed, 116 insertions(+), 35 deletions(-) create mode 100644 src/Extension/Symfony/Bundle/DependencyInjection/FiniteExtension.php create mode 100644 src/Extension/Symfony/Bundle/FiniteBundle.php create mode 100644 tests/Extension/Symfony/ServiceTest.php create mode 100644 tests/Extension/Symfony/fixtures/app/AppKernel.php create mode 100644 tests/Extension/Symfony/fixtures/app/bootstrap.php delete mode 100644 tests/bootstrap.php diff --git a/.dockerignore b/.dockerignore index 4f79572..a878b84 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,4 @@ -vendor -build -bin -composer.phar +tests/Extension/Symfony/fixtures/app/var/ +vendor/ +.phpunit.result.cache composer.lock -cache.properties -coverage.clover -docs/_build diff --git a/.gitignore b/.gitignore index ff369a0..a878b84 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,4 @@ +tests/Extension/Symfony/fixtures/app/var/ +vendor/ .phpunit.result.cache -vendor -build -bin -composer.phar composer.lock -cache.properties -coverage.clover -docs/_build -.phpunit.result.cache diff --git a/Dockerfile b/Dockerfile index a846a7f..1418bb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,14 +6,12 @@ ARG DEPENDENCIES=highest RUN set -eux; \ apk add --no-cache acl libzip; \ - apk add --no-cache --virtual .build-deps ${PHPIZE_DEPS} zlib-dev libzip-dev linux-headers; \ + apk add --no-cache --virtual .build-deps ${PHPIZE_DEPS} zlib-dev libzip-dev; \ docker-php-ext-install zip; \ - pecl install xdebug;\ - docker-php-ext-enable xdebug; \ + pecl install pcov;\ + docker-php-ext-enable pcov; \ apk del .build-deps; -RUN echo "xdebug.mode=coverage" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini - COPY --from=composer:2 /usr/bin/composer /usr/bin/composer WORKDIR /app @@ -25,8 +23,8 @@ RUN set -eux; \ if [ "${DEPENDENCIES}" = "lowest" ]; then COMPOSER_MEMORY_LIMIT=-1 composer update --prefer-lowest --no-interaction; fi; \ if [ "${DEPENDENCIES}" = "highest" ]; then COMPOSER_MEMORY_LIMIT=-1 composer update --no-interaction; fi -COPY ./examples /app/examples -COPY ./src /app/src -COPY ./tests /app/tests -COPY ./phpunit.xml.dist /app/ -COPY ./psalm.xml /app/ +COPY --link ./examples /app/examples +COPY --link ./src /app/src +COPY --link ./tests /app/tests +COPY --link ./phpunit.xml.dist /app/ +COPY --link ./psalm.xml /app/ diff --git a/Makefile b/Makefile index 0c5a92e..d3e54f7 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ cli: docker run -it --rm -v${PWD}:/app -w/app yohang/finite ash test: - docker run -it --rm -v${PWD}:/app -w/app yohang/finite php ./vendor/bin/phpunit + docker run -it --rm -v${PWD}:/app -w/app yohang/finite php ./vendor/bin/phpunit --coverage-text test_all_targets: docker build -t yohang/finite:php-8.1 --build-arg PHP_VERSION=8.1 . diff --git a/composer.json b/composer.json index 9708cf2..f63af81 100644 --- a/composer.json +++ b/composer.json @@ -21,13 +21,15 @@ ], "require": { "php": ">=8.1", - "symfony/property-access": "^6.1|^7.0" + "symfony/property-access": ">=5.4,<8" }, "require-dev": { "phpunit/phpunit": "^10.5.40", - "symfony/var-dumper": "^6.1|^7.0", + "symfony/var-dumper": ">=5.4,<8", "twig/twig": "^3.4", - "vimeo/psalm": "dev-master@dev" + "vimeo/psalm": "dev-master@dev", + "symfony/http-kernel": ">=5.4,<8", + "symfony/framework-bundle": ">=5.4,<8" }, "autoload": { "psr-4": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 2044d9f..5986202 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,7 +2,7 @@ diff --git a/src/Extension/Symfony/Bundle/DependencyInjection/FiniteExtension.php b/src/Extension/Symfony/Bundle/DependencyInjection/FiniteExtension.php new file mode 100644 index 0000000..39c5caf --- /dev/null +++ b/src/Extension/Symfony/Bundle/DependencyInjection/FiniteExtension.php @@ -0,0 +1,23 @@ +addDefinitions( + [ + StateMachine::class => (new Definition(StateMachine::class))->setPublic(true), + TwigExtension::class => new Definition(TwigExtension::class), + ] + ); + } +} diff --git a/src/Extension/Symfony/Bundle/FiniteBundle.php b/src/Extension/Symfony/Bundle/FiniteBundle.php new file mode 100644 index 0000000..a995b08 --- /dev/null +++ b/src/Extension/Symfony/Bundle/FiniteBundle.php @@ -0,0 +1,10 @@ +assertInstanceOf(StateMachine::class, $container->get(StateMachine::class)); + } + + protected static function getKernelClass(): string + { + return \AppKernel::class; + } +} diff --git a/tests/Extension/Symfony/fixtures/app/AppKernel.php b/tests/Extension/Symfony/fixtures/app/AppKernel.php new file mode 100644 index 0000000..428781e --- /dev/null +++ b/tests/Extension/Symfony/fixtures/app/AppKernel.php @@ -0,0 +1,31 @@ +prependExtensionConfig('framework', ['test' => true]); + } + + public function getProjectDir(): string + { + return __DIR__; + } + +} diff --git a/tests/Extension/Symfony/fixtures/app/bootstrap.php b/tests/Extension/Symfony/fixtures/app/bootstrap.php new file mode 100644 index 0000000..b58eb5b --- /dev/null +++ b/tests/Extension/Symfony/fixtures/app/bootstrap.php @@ -0,0 +1,7 @@ +add('Finite\Test', __DIR__); From 254cb6f053c5e5ccbba270b5aaa8cc021d52b55a Mon Sep 17 00:00:00 2001 From: Yohan Giarelli Date: Mon, 13 Jan 2025 22:22:01 +0100 Subject: [PATCH 2/2] feat(symfony): Connected StateMachine to Symfony EventDispatcher --- .dockerignore | 2 +- .gitignore | 2 +- Dockerfile | 2 +- Makefile | 2 +- composer.json | 7 ++-- phpunit.xml.dist | 2 +- psalm.xml | 2 +- src/Event/Event.php | 4 ++- src/Event/EventDispatcher.php | 9 ++++-- .../DependencyInjection/FiniteExtension.php | 5 ++- src/StateMachine.php | 19 ++++++----- src/Transition/Transition.php | 2 ++ tests/E2E/BasicGraphTest.php | 8 +++++ .../FiniteExtensionTest.php | 31 ++++++++++++++++++ .../Symfony/Fixtures/Model/Document.php | 11 +++++++ .../Symfony/Fixtures/State/DocumentState.php | 25 +++++++++++++++ .../{fixtures => Fixtures}/app/AppKernel.php | 3 +- .../{fixtures => Fixtures}/app/bootstrap.php | 0 tests/Extension/Symfony/ServiceTest.php | 22 +++++++++++-- tests/Extension/Twig/FiniteExtensionTest.php | 32 +++++++++++++++++++ tests/StateMachineTest.php | 29 ++++++++++++++--- 21 files changed, 188 insertions(+), 31 deletions(-) create mode 100644 tests/Extension/Symfony/Bundle/DependencyInjection/FiniteExtensionTest.php create mode 100644 tests/Extension/Symfony/Fixtures/Model/Document.php create mode 100644 tests/Extension/Symfony/Fixtures/State/DocumentState.php rename tests/Extension/Symfony/{fixtures => Fixtures}/app/AppKernel.php (84%) rename tests/Extension/Symfony/{fixtures => Fixtures}/app/bootstrap.php (100%) create mode 100644 tests/Extension/Twig/FiniteExtensionTest.php diff --git a/.dockerignore b/.dockerignore index a878b84..176e243 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,4 @@ -tests/Extension/Symfony/fixtures/app/var/ +tests/Extension/Symfony/Fixtures/app/var/ vendor/ .phpunit.result.cache composer.lock diff --git a/.gitignore b/.gitignore index a878b84..176e243 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -tests/Extension/Symfony/fixtures/app/var/ +tests/Extension/Symfony/Fixtures/app/var/ vendor/ .phpunit.result.cache composer.lock diff --git a/Dockerfile b/Dockerfile index 1418bb9..66b7df1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ ARG DEPENDENCIES=highest RUN set -eux; \ apk add --no-cache acl libzip; \ apk add --no-cache --virtual .build-deps ${PHPIZE_DEPS} zlib-dev libzip-dev; \ - docker-php-ext-install zip; \ + docker-php-ext-install zip opcache; \ pecl install pcov;\ docker-php-ext-enable pcov; \ apk del .build-deps; diff --git a/Makefile b/Makefile index d3e54f7..a4f3b24 100644 --- a/Makefile +++ b/Makefile @@ -27,4 +27,4 @@ test_all_targets: psalm: docker build -t yohang/finite:php-8.4 --build-arg PHP_VERSION=8.4 . - docker run -it --rm yohang/finite:php-8.4 php ./vendor/bin/psalm --show-info=true + docker run -it --rm yohang/finite:php-8.4 php ./vendor/bin/psalm --show-info=true --no-diff diff --git a/composer.json b/composer.json index f63af81..8b93c8e 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,8 @@ ], "require": { "php": ">=8.1", - "symfony/property-access": ">=5.4,<8" + "symfony/property-access": ">=5.4,<8", + "psr/event-dispatcher": "^1.0" }, "require-dev": { "phpunit/phpunit": "^10.5.40", @@ -29,7 +30,9 @@ "twig/twig": "^3.4", "vimeo/psalm": "dev-master@dev", "symfony/http-kernel": ">=5.4,<8", - "symfony/framework-bundle": ">=5.4,<8" + "symfony/framework-bundle": ">=5.4,<8", + "symfony/event-dispatcher": ">=5.4,<8", + "symfony/stopwatch": ">=5.4,<8" }, "autoload": { "psr-4": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5986202..f94b046 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,7 +2,7 @@ diff --git a/psalm.xml b/psalm.xml index 4d5b24a..9b8659d 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,6 +1,6 @@ > @@ -18,7 +21,7 @@ public function addEventListener(string $eventClass, callable $listener): void $this->listeners[$eventClass][] = $listener; } - public function dispatch(Event $event): void + public function dispatch(object $event): void { if (!isset($this->listeners[get_class($event)])) { return; @@ -27,7 +30,7 @@ public function dispatch(Event $event): void foreach ($this->listeners[get_class($event)] as $listener) { $listener($event); - if ($event->isPropagationStopped()) { + if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) { return; } } diff --git a/src/Extension/Symfony/Bundle/DependencyInjection/FiniteExtension.php b/src/Extension/Symfony/Bundle/DependencyInjection/FiniteExtension.php index 39c5caf..cf2a123 100644 --- a/src/Extension/Symfony/Bundle/DependencyInjection/FiniteExtension.php +++ b/src/Extension/Symfony/Bundle/DependencyInjection/FiniteExtension.php @@ -8,6 +8,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Extension\Extension; +use Symfony\Component\DependencyInjection\Reference; final class FiniteExtension extends Extension { @@ -15,7 +16,9 @@ public function load(array $configs, ContainerBuilder $container): void { $container->addDefinitions( [ - StateMachine::class => (new Definition(StateMachine::class))->setPublic(true), + StateMachine::class => (new Definition(StateMachine::class)) + ->setArgument('$dispatcher', new Reference('event_dispatcher')) + ->setPublic(true), TwigExtension::class => new Definition(TwigExtension::class), ] ); diff --git a/src/StateMachine.php b/src/StateMachine.php index 7603f85..4e7bb0b 100644 --- a/src/StateMachine.php +++ b/src/StateMachine.php @@ -7,15 +7,15 @@ use Finite\Event\PostTransitionEvent; use Finite\Event\PreTransitionEvent; use Finite\Transition\TransitionInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\PropertyAccess\PropertyAccess; class StateMachine { public function __construct( - private readonly EventDispatcher $dispatcher = new EventDispatcher, + private readonly EventDispatcherInterface $dispatcher = new EventDispatcher, ) { - } /** @@ -85,6 +85,11 @@ public function getReachablesTransitions(object $object, ?string $stateClass = n ); } + public function getDispatcher(): EventDispatcherInterface + { + return $this->dispatcher; + } + /** * @param class-string|null $stateClass */ @@ -92,6 +97,7 @@ private function extractState(object $object, ?string $stateClass = null): State { $property = $this->extractStateProperty($object, $stateClass); + /** @psalm-suppress MixedReturnStatement */ return PropertyAccess::createPropertyAccessor()->getValue($object, $property->getName()); } @@ -107,11 +113,8 @@ private function extractStateProperty(object $object, ?string $stateClass = null $reflectionClass = new \ReflectionClass($object); /** @psalm-suppress DocblockTypeContradiction */ do { - if (!$reflectionClass) { - throw new \InvalidArgumentException('Found no state on object ' . get_class($object)); - } - foreach ($reflectionClass->getProperties() as $property) { + /** @var \ReflectionUnionType|\ReflectionIntersectionType|\ReflectionNamedType $reflectionType */ $reflectionType = $property->getType(); if (null === $reflectionType) { continue; @@ -125,10 +128,6 @@ private function extractStateProperty(object $object, ?string $stateClass = null continue; } - if (!$reflectionType instanceof \ReflectionNamedType) { - continue; - } - /** @var class-string $name */ $name = $reflectionType->getName(); if (!enum_exists($name)) { diff --git a/src/Transition/Transition.php b/src/Transition/Transition.php index f230ae6..31f86ae 100644 --- a/src/Transition/Transition.php +++ b/src/Transition/Transition.php @@ -14,8 +14,10 @@ class Transition implements TransitionInterface { public function __construct( public readonly string $name, + /** @var State[] */ public readonly array $sourceStates, public readonly State $targetState, + /** @var array */ public readonly array $properties = [] ) { diff --git a/tests/E2E/BasicGraphTest.php b/tests/E2E/BasicGraphTest.php index bbb97f8..5c3e904 100644 --- a/tests/E2E/BasicGraphTest.php +++ b/tests/E2E/BasicGraphTest.php @@ -7,6 +7,14 @@ class Article { + public $noTypeHere = null; + + public int|float $unionType = 0; + + public \Traversable&\Countable $intersectionType; + + public string $namedType = 'named'; + private SimpleArticleState $state = SimpleArticleState::DRAFT; private readonly \DateTimeInterface $createdAt; diff --git a/tests/Extension/Symfony/Bundle/DependencyInjection/FiniteExtensionTest.php b/tests/Extension/Symfony/Bundle/DependencyInjection/FiniteExtensionTest.php new file mode 100644 index 0000000..143833e --- /dev/null +++ b/tests/Extension/Symfony/Bundle/DependencyInjection/FiniteExtensionTest.php @@ -0,0 +1,31 @@ +getMockBuilder(ContainerBuilder::class) + ->disableOriginalConstructor() + ->getMock(); + + $container->expects($this->once())->method('addDefinitions')->with( + $this->logicalAnd( + $this->countOf(2), + $this->arrayHasKey(StateMachine::class), + $this->arrayHasKey(TwigExtension::class), + ), + ); + + $extension = new FiniteExtension; + $extension->load([], $container); + } +} diff --git a/tests/Extension/Symfony/Fixtures/Model/Document.php b/tests/Extension/Symfony/Fixtures/Model/Document.php new file mode 100644 index 0000000..58a927f --- /dev/null +++ b/tests/Extension/Symfony/Fixtures/Model/Document.php @@ -0,0 +1,11 @@ +prependExtensionConfig('framework', ['test' => true]); + $c->setParameter('kernel.debug', true); + $c->prependExtensionConfig('framework', ['test' => true, 'profiler' => true]); } public function getProjectDir(): string diff --git a/tests/Extension/Symfony/fixtures/app/bootstrap.php b/tests/Extension/Symfony/Fixtures/app/bootstrap.php similarity index 100% rename from tests/Extension/Symfony/fixtures/app/bootstrap.php rename to tests/Extension/Symfony/Fixtures/app/bootstrap.php diff --git a/tests/Extension/Symfony/ServiceTest.php b/tests/Extension/Symfony/ServiceTest.php index 1a4e4d5..62fec84 100644 --- a/tests/Extension/Symfony/ServiceTest.php +++ b/tests/Extension/Symfony/ServiceTest.php @@ -3,19 +3,35 @@ namespace Finite\Tests\Extension\Symfony; -use Finite\Extension\Twig\FiniteExtension; +use Finite\Event\CanTransitionEvent; use Finite\StateMachine; +use Finite\Tests\Extension\Symfony\Fixtures\Model\Document; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; -use Symfony\Bundle\FrameworkBundle\Test\TestContainer; +use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; class ServiceTest extends KernelTestCase { public function test_services_are_registered(): void { - /** @var TestContainer $container */ $container = static::getContainer(); $this->assertInstanceOf(StateMachine::class, $container->get(StateMachine::class)); + $this->assertInstanceOf(EventDispatcherInterface::class, $container->get(StateMachine::class)->getDispatcher()); + } + + public function test_it_uses_the_symfony_dispatcher(): void + { + $container = static::getContainer(); + + /** @var StateMachine $stateMachine */ + $stateMachine = $container->get(StateMachine::class); + $stateMachine->can(new Document, 'publish'); + + /** @var TraceableEventDispatcher $debugDispatcher */ + $debugDispatcher = $container->get('debug.event_dispatcher'); + + $this->assertSame(CanTransitionEvent::class, $debugDispatcher->getOrphanedEvents()[0]); } protected static function getKernelClass(): string diff --git a/tests/Extension/Twig/FiniteExtensionTest.php b/tests/Extension/Twig/FiniteExtensionTest.php new file mode 100644 index 0000000..d0d8923 --- /dev/null +++ b/tests/Extension/Twig/FiniteExtensionTest.php @@ -0,0 +1,32 @@ +createMock(\stdClass::class); + $stateMachine = $this->createMock(StateMachine::class); + + $functions = (new FiniteExtension($stateMachine))->getFunctions(); + + $functions = array_combine( + array_map(fn($function) => $function->getName(), $functions), + $functions, + ); + $this->assertArrayHasKey('finite_can', $functions); + $this->assertArrayHasKey('finite_reachable_transitions', $functions); + + $stateMachine->expects($this->once())->method('can')->with($object, 'publish')->willReturn(true); + $functions['finite_can']->getCallable()($object, 'publish'); + + $stateMachine->expects($this->once())->method('getReachablesTransitions')->with($object)->willReturn([]); + $functions['finite_reachable_transitions']->getCallable()($object); + } +} diff --git a/tests/StateMachineTest.php b/tests/StateMachineTest.php index 1cd0dd5..abde7a7 100644 --- a/tests/StateMachineTest.php +++ b/tests/StateMachineTest.php @@ -9,14 +9,28 @@ use Finite\Tests\E2E\Article; use Finite\Tests\E2E\SimpleArticleState; use PHPUnit\Framework\TestCase; +use Psr\EventDispatcher\EventDispatcherInterface; class StateMachineTest extends TestCase { + public function test_it_instantiate_event_dispatcher(): void + { + $this->assertInstanceOf(EventDispatcher::class, (new StateMachine)->getDispatcher()); + $this->assertInstanceOf(EventDispatcherInterface::class, (new StateMachine)->getDispatcher()); + } + + public function test_it_use_constructor_event_dispatcher(): void + { + $eventDispatcher = $this->getMockBuilder(EventDispatcherInterface::class)->getMock(); + + $this->assertSame($eventDispatcher, (new StateMachine($eventDispatcher))->getDispatcher()); + } + public function test_it_can_transition(): void { $object = new Article('Hi !'); - $eventDispatcher = $this->getMockBuilder(EventDispatcher::class)->getMock(); + $eventDispatcher = $this->getMockBuilder(EventDispatcherInterface::class)->getMock(); $eventDispatcher ->expects($this->once()) ->method('dispatch') @@ -29,7 +43,6 @@ public function test_it_can_transition(): void }), ); - $stateMachine = new StateMachine($eventDispatcher); $this->assertTrue($stateMachine->can($object, SimpleArticleState::PUBLISH)); @@ -39,7 +52,7 @@ public function test_it_blocks_transition(): void { $object = new Article('Hi !'); - $eventDispatcher = $this->getMockBuilder(EventDispatcher::class)->getMock(); + $eventDispatcher = $this->getMockBuilder(EventDispatcherInterface::class)->getMock(); $eventDispatcher ->expects($this->once()) ->method('dispatch') @@ -56,7 +69,7 @@ public function test_it_applies_transition(): void { $object = new Article('Hi !'); - $eventDispatcher = $this->getMockBuilder(EventDispatcher::class)->getMock(); + $eventDispatcher = $this->getMockBuilder(EventDispatcherInterface::class)->getMock(); $matcher = $this->exactly(6); $eventDispatcher @@ -93,4 +106,12 @@ public function test_it_rejects_unexistant_state_class() (new StateMachine)->can(new \stdClass, 'transition', 'Unexistant enum'); } + + public function test_it_throws_if_no_state(): void + { + $this->expectException(\InvalidArgumentException::class); + + $stateMachine = new StateMachine; + $stateMachine->can(new class extends \stdClass {}, 'transition'); + } }