diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index f363898..fcc7247 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - php-versions: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + php-versions: ['8.2', '8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v2 @@ -43,8 +43,5 @@ jobs: - name: PHPStan run: bin/phpstan - - name: Composer validate - run: composer validate - - # - name: Infection -# run: bin/infection --formatter=progress + - name: Infection + run: bin/infection --formatter=progress diff --git a/composer.json b/composer.json index fda8381..823bde7 100644 --- a/composer.json +++ b/composer.json @@ -20,20 +20,23 @@ } }, "require": { - "php": ">=7.4|^8.0", + "php": ">=8.2", "webmozart/assert": "^1.0", - "symfony/event-dispatcher": "^4.0|^5.0|^6.0|^7.0" + "symfony/event-dispatcher": "^7.0||^8.0" }, "require-dev": { "ext-pdo": "*", - "doctrine/annotations": "^1.13", - "doctrine/orm": "^2.5", + "doctrine/orm": ">=2.5", "infection/infection": "~0.13", "phpstan/phpstan": "^2.0", "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^12.0", "squizlabs/php_codesniffer": "^3.2", - "symfony/cache": "^4.0|^5.0|^6.0" + "symfony/cache": ">=7.0", + "symfony/var-exporter": "^7.0" + }, + "scripts": { + "test": "bin/phpunit; bin/phpcs; bin/phpstan; bin/infection" }, "config": { "bin-dir": "bin", diff --git a/examples/ContextUsingBuilderTest.php b/examples/ContextUsingBuilderTest.php index 848ebbc..1951dd7 100644 --- a/examples/ContextUsingBuilderTest.php +++ b/examples/ContextUsingBuilderTest.php @@ -1,9 +1,4 @@ markTestSkipped('Sqlite extension is needed'); } - $configuration = Setup::createAnnotationMetadataConfiguration([__DIR__], true); - $this->em = EntityManager::create( + $configuration = ORMSetup::createAttributeMetadataConfiguration([__DIR__], true); + $connection = DriverManager::getConnection( [ 'driver' => 'pdo_sqlite', 'in_memory' => true, ], - $configuration + $configuration, ); + $this->em = new EntityManager($connection, $configuration); $tool = new SchemaTool($this->em); $tool->createSchema([$this->em->getClassMetadata(MyEntity::class)]); } @@ -82,24 +79,16 @@ private function save(MyEntity $entity): MyEntity } } -/** - * @Entity() - */ +#[ORM\Entity] class MyEntity { - /** - * @var int - * @Id() - * @GeneratedValue(strategy="AUTO") - * @Column(name="id", type="integer") - */ + #[ORM\Id()] + #[ORM\GeneratedValue(strategy: "AUTO")] + #[ORM\Column(name: "id", type: "integer")] public int $id; - /** - * @var MyState|StateMetadata - * @Embedded(class="MyState", columnPrefix="my_") - */ - private $state; + #[ORM\Embedded(class: "MyState", columnPrefix: "my_")] + private MyState $state; public function __construct() { @@ -122,15 +111,10 @@ public function unlock(): void } } -/** - * @Embeddable() - */ +#[ORM\Embeddable] final class MyState extends StateMetadata { - /** - * @var string - * @Column(name="state", type="string") - */ + #[ORM\Column(name: "state", type: "string")] protected string $current; public function __construct() diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 63df743..6dc3d51 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -2,6 +2,7 @@ + src examples diff --git a/src/Builder/StateBuilder.php b/src/Builder/StateBuilder.php index 3033e4a..dbc9ca1 100644 --- a/src/Builder/StateBuilder.php +++ b/src/Builder/StateBuilder.php @@ -9,6 +9,7 @@ use Star\Component\State\TransitionRegistry; use Star\Component\State\Transitions\ManyToOneTransition; use Star\Component\State\Transitions\OneToOneTransition; +use function is_array; /** * Tool to build the StateMachine. @@ -16,12 +17,22 @@ final class StateBuilder { private TransitionRegistry $registry; - private EventRegistry $listeners; + private EventRegistry /* todo use StateRegistry */ $listeners; - public function __construct() - { - $this->registry = new TransitionRegistry(); - $this->listeners = new EventDispatcherAdapter(); + public function __construct( + ?TransitionRegistry $registry = null, + ?EventRegistry $listeners = null, + ) { + /** @todo deprecated nullable construct, move to private */ + if (!$registry) { + $registry = new TransitionRegistry(); + } + $this->registry = $registry; + + if (!$listeners) { + $listeners = new EventDispatcherAdapter(); + } + $this->listeners = $listeners; } /** @@ -31,9 +42,9 @@ public function __construct() * * @return StateBuilder */ - public function allowTransition(string $name, $from, string $to): StateBuilder + public function allowTransition(string $name, string|array $from, string $to): StateBuilder { - if (\is_array($from)) { + if (is_array($from)) { $transition = new ManyToOneTransition($name, $to, ...$from); } else { $transition = new OneToOneTransition($name, $from, $to); @@ -49,6 +60,7 @@ public function allowTransition(string $name, $from, string $to): StateBuilder */ public function allowCustomTransition(StateTransition $transition): void { + // todo add interface for registry, and inject interface instead $this->registry->addTransition($transition); } @@ -58,7 +70,7 @@ public function allowCustomTransition(StateTransition $transition): void * * @return StateBuilder */ - public function addAttribute(string $attribute, $states): StateBuilder + public function addAttribute(string $attribute, string|array $states): StateBuilder { $states = (array) $states; foreach ($states as $stateName) { @@ -73,8 +85,10 @@ public function create(string $currentState): StateMachine return new StateMachine($currentState, $this->registry, $this->listeners); } - public static function build(): StateBuilder - { - return new static(); + public static function build( + ?TransitionRegistry $registry = null, + ?EventRegistry $listeners = null, + ): StateBuilder { + return new self($registry, $listeners); } } diff --git a/src/Callbacks/BufferStateChanges.php b/src/Callbacks/BufferStateChanges.php new file mode 100644 index 0000000..94e36a6 --- /dev/null +++ b/src/Callbacks/BufferStateChanges.php @@ -0,0 +1,51 @@ +> + */ + private array $buffer = []; + + public function beforeStateChange($context, StateMachine $machine): void + { + if (is_object($context)) { + $context = get_class($context); + } + Assert::string($context, 'Context is expected to be a string. Got: %s'); + + $this->buffer[$context][] = __FUNCTION__; + } + + public function afterStateChange($context, StateMachine $machine): void + { + if (is_object($context)) { + $context = get_class($context); + } + Assert::string($context, 'Context is expected to be a string. Got: %s'); + + $this->buffer[$context][] = __FUNCTION__; + } + + public function onFailure(InvalidStateTransitionException $exception, $context, StateMachine $machine): string + { + throw new RuntimeException(__METHOD__ . ' is not implemented yet.'); + } + + /** + * @return array> + */ + public function flushBuffer(): array + { + return $this->buffer; + } +} diff --git a/src/Event/StateEventStore.php b/src/Event/StateEventStore.php index 197b5db..18929b3 100644 --- a/src/Event/StateEventStore.php +++ b/src/Event/StateEventStore.php @@ -1,12 +1,11 @@ self::BEFORE_TRANSITION, + TransitionWasSuccessful::class => self::AFTER_TRANSITION, + TransitionWasFailed::class => self::FAILURE_TRANSITION, + default => throw new InvalidArgumentException( + sprintf( + 'Event "%s" is not mapped to a name.', + get_class($event), + ) + ), + }; + } } diff --git a/src/Event/TransitionWasRequested.php b/src/Event/TransitionWasRequested.php index 06c62f0..9b93983 100644 --- a/src/Event/TransitionWasRequested.php +++ b/src/Event/TransitionWasRequested.php @@ -1,9 +1,4 @@ dispatcher->dispatch($event); + $this->dispatcher->dispatch($event, StateEventStore::eventNameFromClass($event)); } public function addListener(string $event, callable $listener): void diff --git a/src/StateMachine.php b/src/StateMachine.php index 16748c3..19eeb38 100644 --- a/src/StateMachine.php +++ b/src/StateMachine.php @@ -1,9 +1,4 @@ hasState($state)) { - $attributes = \array_merge($this->states[$state], $attributes); + $attributes = array_merge($this->states[$state], $attributes); } - $this->states[$state] = \array_unique($attributes); + $this->states[$state] = array_unique($attributes); } /** @@ -74,7 +74,7 @@ public function addAttribute(string $state, string $attribute): void */ private function addAttributes(string $state, array $attributes): void { - \array_map( + array_map( function ($attribute) use ($state) { $this->addAttribute($state, $attribute); }, @@ -89,7 +89,7 @@ public function transitionStartsFrom(string $transition, string $state): bool $from = $this->transitions[$transition]['from']; } - return \in_array($state, $from, true); // @phpstan-ignore-line + return in_array($state, $from, true); // @phpstan-ignore-line } public function hasAttribute(string $state, string $attribute): bool @@ -98,12 +98,12 @@ public function hasAttribute(string $state, string $attribute): bool return false; } - return \in_array($attribute, $this->states[$state]); + return in_array($attribute, $this->states[$state]); } public function hasState(string $name): bool { - return \array_key_exists($name, $this->states); + return array_key_exists($name, $this->states); } public function acceptTransitionVisitor(TransitionVisitor $visitor): void diff --git a/src/Visitor/AttributeDumper.php b/src/Visitor/AttributeDumper.php index 9d237dc..53f212b 100644 --- a/src/Visitor/AttributeDumper.php +++ b/src/Visitor/AttributeDumper.php @@ -8,16 +8,16 @@ final class AttributeDumper implements StateVisitor { /** * An array having the state name as key and the attributes of this state. - * @var string[][] + * @var array */ - private array $structure = []; + private array $attributesByStates = []; /** - * @return string[][] + * @return array */ public function getStructure(): array { - return $this->structure; + return $this->attributesByStates; } /** @@ -26,10 +26,6 @@ public function getStructure(): array */ public function visitState(string $name, array $attributes): void { - if (!isset($this->structure[$name])) { - $this->structure[$name] = []; - } - - $this->structure[$name] = array_unique(array_merge($this->structure[$name], $attributes)); + $this->attributesByStates[$name] = $attributes; } } diff --git a/tests/Callbacks/BufferStateChangesTest.php b/tests/Callbacks/BufferStateChangesTest.php new file mode 100644 index 0000000..18ace69 --- /dev/null +++ b/tests/Callbacks/BufferStateChangesTest.php @@ -0,0 +1,84 @@ +create(''); + + $buffer->beforeStateChange( + (object) [], + $machine + ); + $buffer->afterStateChange( + (object) [], + $machine + ); + + self::assertSame( + [ + 'stdClass' => [ + 0 => 'beforeStateChange', + 1 => 'afterStateChange', + ], + ], + $buffer->flushBuffer(), + ); + } + + public function test_it_should_buffer_context_as_string(): void + { + $buffer = new BufferStateChanges(); + $machine = StateBuilder::build() + ->create(''); + + $buffer->beforeStateChange( + 'stdClass', + $machine + ); + $buffer->afterStateChange( + 'stdClass', + $machine + ); + + self::assertSame( + [ + 'stdClass' => [ + 0 => 'beforeStateChange', + 1 => 'afterStateChange', + ], + ], + $buffer->flushBuffer(), + ); + } + + public function test_it_should_not_allow_non_string_context_in_before(): void + { + $buffer = new BufferStateChanges(); + $machine = StateBuilder::build() + ->create(''); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Context is expected to be a string. Got: integer'); + $buffer->beforeStateChange(42, $machine); + } + + public function test_it_should_not_allow_non_string_context_in_after(): void + { + $buffer = new BufferStateChanges(); + $machine = StateBuilder::build() + ->create(''); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Context is expected to be a string. Got: integer'); + $buffer->afterStateChange(42, $machine); + } +} diff --git a/tests/Event/StateEventStoreTest.php b/tests/Event/StateEventStoreTest.php new file mode 100644 index 0000000..1d651b0 --- /dev/null +++ b/tests/Event/StateEventStoreTest.php @@ -0,0 +1,16 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('is not mapped to a name.'); + StateEventStore::eventNameFromClass($this->createStub(StateEvent::class)); + } +} diff --git a/tests/StateBuilderTest.php b/tests/StateBuilderTest.php index 5a9c854..e4ee0c4 100644 --- a/tests/StateBuilderTest.php +++ b/tests/StateBuilderTest.php @@ -4,6 +4,8 @@ use PHPUnit\Framework\TestCase; use Star\Component\State\Builder\StateBuilder; +use Star\Component\State\Event\StateEventStore; +use Star\Component\State\Port\Symfony\EventDispatcherAdapter; final class StateBuilderTest extends TestCase { @@ -34,4 +36,29 @@ public function test_it_should_return_whether_the_current_state_has_attribute_af self::assertTrue($machine->isInState('to')); self::assertFalse($machine->hasAttribute('attr')); } + + public function test_it_should_dispatch_event_on_transit(): void + { + $i = 0; + $addition = function () use (&$i): void { + $i ++; + }; + + $listeners = new EventDispatcherAdapter(); + $listeners->addListener( + StateEventStore::BEFORE_TRANSITION, + $addition + ); + $listeners->addListener( + StateEventStore::AFTER_TRANSITION, + $addition + ); + + $machine = StateBuilder::build(listeners: $listeners) + ->allowTransition('t', 'from', 'to') + ->create('from'); + + $machine->transit('t', 'c'); + self::assertSame(2, $i); + } } diff --git a/tests/StateMachineTest.php b/tests/StateMachineTest.php index 07d134e..203ecb4 100644 --- a/tests/StateMachineTest.php +++ b/tests/StateMachineTest.php @@ -1,13 +1,10 @@ createMock(StateRegistry::class); - $machine = new StateMachine('', $registry, $this->events); $visitor = $this->createStub(TransitionVisitor::class); - + $registry = $this->createMock(StateRegistry::class); $registry ->expects($this->once()) ->method('acceptTransitionVisitor') ->with($visitor); + + $machine = new StateMachine('', $registry, $this->events); + $machine->acceptTransitionVisitor($visitor); } @@ -136,4 +134,31 @@ public function test_it_should_dispatch_an_event_before_a_transition_has_failed( self::assertCount(1, $events); self::assertContainsOnlyInstancesOf(TransitionWasFailed::class, $events); } + + public function test_it_should_invoke_before_state_change_callback(): void + { + $this->registry->addTransition(new OneToOneTransition('t', 'current', 'to')); + $buffer = new BufferStateChanges(); + + self::assertSame( + [], + $buffer->flushBuffer(), + ); + + $this->machine->transit( + 't', + 'context', + $buffer, + ); + + self::assertSame( + [ + 'context' => [ + 'beforeStateChange', + 'afterStateChange', + ], + ], + $buffer->flushBuffer(), + ); + } } diff --git a/tests/TestContext.php b/tests/TestContext.php index 2543164..a6f33ef 100644 --- a/tests/TestContext.php +++ b/tests/TestContext.php @@ -1,9 +1,4 @@ attributes ); } + + public function test_it_should_add_unique_attributes(): void + { + $this->registry->addAttribute('a', '1'); + $this->registry->addAttribute('a', '2'); + $this->registry->addAttribute('a', '2'); + $this->registry->addAttribute('a', '3'); + $this->registry->addAttribute('a', '3'); + + $this->registry->acceptStateVisitor($visitor = new AttributeDumper()); + self::assertSame( + [ + 'a' => [ + '1', + '2', + '3', + ], + ], + $visitor->getStructure() + ); + } } diff --git a/tests/Visitor/AttributeDumperTest.php b/tests/Visitor/AttributeDumperTest.php index 10d041c..54476b5 100644 --- a/tests/Visitor/AttributeDumperTest.php +++ b/tests/Visitor/AttributeDumperTest.php @@ -4,25 +4,19 @@ use PHPUnit\Framework\TestCase; use Star\Component\State\Builder\StateBuilder; -use Star\Component\State\StateMachine; final class AttributeDumperTest extends TestCase { - private StateMachine $machine; - - public function setUp(): void + public function test_it_should_dump_the_attributes(): void { - $this->machine = StateBuilder::build() + $machine = StateBuilder::build() ->allowTransition('t1', 's1', 's2') ->allowTransition('t2', ['s2', 's3'], 's1') ->addAttribute('a1', 's1') ->addAttribute('a2', ['s1', 's2']) ->create('s1'); - } + $machine->acceptStateVisitor($dumper = new AttributeDumper()); - public function test_it_should_dump_the_attributes(): void - { - $this->machine->acceptStateVisitor($dumper = new AttributeDumper()); self::assertEquals( [ 's1' => ['a1', 'a2'], @@ -32,4 +26,24 @@ public function test_it_should_dump_the_attributes(): void $dumper->getStructure() ); } + + public function test_it_should_dump_unique_attributes_by_state(): void + { + $machine = StateBuilder::build() + ->allowTransition('t1', 's1', 's2') + ->addAttribute('a1', ['s1', 's2']) + ->addAttribute('a2', ['s1', 's2']) + ->addAttribute('a3', ['s1', 's2']) + ->addAttribute('a4', ['s1', 's2']) + ->create('s1'); + $machine->acceptStateVisitor($dumper = new AttributeDumper()); + + self::assertEquals( + [ + 's1' => ['a1', 'a2', 'a3', 'a4'], + 's2' => ['a1', 'a2', 'a3', 'a4'], + ], + $dumper->getStructure() + ); + } }