diff --git a/.dockerignore b/.dockerignore index 4f79572..176e243 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..176e243 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..66b7df1 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; \ - docker-php-ext-install zip; \ - pecl install xdebug;\ - docker-php-ext-enable xdebug; \ + apk add --no-cache --virtual .build-deps ${PHPIZE_DEPS} zlib-dev libzip-dev; \ + docker-php-ext-install zip opcache; \ + 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..a4f3b24 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 . @@ -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 9708cf2..8b93c8e 100644 --- a/composer.json +++ b/composer.json @@ -21,13 +21,18 @@ ], "require": { "php": ">=8.1", - "symfony/property-access": "^6.1|^7.0" + "symfony/property-access": ">=5.4,<8", + "psr/event-dispatcher": "^1.0" }, "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", + "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 2044d9f..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 new file mode 100644 index 0000000..cf2a123 --- /dev/null +++ b/src/Extension/Symfony/Bundle/DependencyInjection/FiniteExtension.php @@ -0,0 +1,26 @@ +addDefinitions( + [ + StateMachine::class => (new Definition(StateMachine::class)) + ->setArgument('$dispatcher', new Reference('event_dispatcher')) + ->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 @@ +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 @@ +setParameter('kernel.debug', true); + $c->prependExtensionConfig('framework', ['test' => true, 'profiler' => 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 @@ +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 + { + return \AppKernel::class; + } +} 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'); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index 88abca9..0000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,5 +0,0 @@ -add('Finite\Test', __DIR__);