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