diff --git a/composer.json b/composer.json index 0623f47..801e219 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,8 @@ "symfony/stopwatch": ">=5.4,<8", "symfony/twig-bundle": ">=5.4,<8", "symfony/browser-kit": ">=5.4,<8", - "symfony/css-selector": ">=5.4,<8" + "symfony/css-selector": ">=5.4,<8", + "symfony/console": ">=5.4,<8" }, "autoload": { "psr-4": { diff --git a/src/Dumper/Dumper.php b/src/Dumper/Dumper.php new file mode 100644 index 0000000..a025ccd --- /dev/null +++ b/src/Dumper/Dumper.php @@ -0,0 +1,13 @@ + $stateEnum + */ + public function dump(string $stateEnum): string; +} diff --git a/src/Dumper/MermaidDumper.php b/src/Dumper/MermaidDumper.php new file mode 100644 index 0000000..5fcb67f --- /dev/null +++ b/src/Dumper/MermaidDumper.php @@ -0,0 +1,34 @@ + $stateEnum + */ + public function dump(string $stateEnum): string + { + $output = [ + '---', + 'title: ' . $stateEnum, + '---', + 'stateDiagram-v2', + ]; + + foreach ($stateEnum::getTransitions() as $transition) { + foreach ($transition->getSourceStates() as $state) { + $output[] = sprintf( + ' %s --> %s: %s', + $state->value, + $transition->getTargetState()->value, + $transition->getName() + ); + } + } + + return implode(PHP_EOL, $output); + } +} diff --git a/src/Extension/Symfony/Bundle/FiniteBundle.php b/src/Extension/Symfony/Bundle/FiniteBundle.php index a995b08..e70e7d8 100644 --- a/src/Extension/Symfony/Bundle/FiniteBundle.php +++ b/src/Extension/Symfony/Bundle/FiniteBundle.php @@ -3,8 +3,14 @@ namespace Finite\Extension\Symfony\Bundle; +use Finite\Extension\Symfony\Command\DumpStateMachineCommand; +use Symfony\Component\Console\Application; use Symfony\Component\HttpKernel\Bundle\Bundle; final class FiniteBundle extends Bundle { + public function registerCommands(Application $application): void + { + $application->add(new DumpStateMachineCommand); + } } diff --git a/src/Extension/Symfony/Command/DumpStateMachineCommand.php b/src/Extension/Symfony/Command/DumpStateMachineCommand.php new file mode 100644 index 0000000..ec05021 --- /dev/null +++ b/src/Extension/Symfony/Command/DumpStateMachineCommand.php @@ -0,0 +1,53 @@ +setName('finite:state-machine:dump') + ->setDescription('Dump the state machine graph into requested format') + ->addArgument('state_enum', InputArgument::REQUIRED, 'The state enum to use') + ->addArgument('format', InputArgument::REQUIRED, 'The format to dump the graph in'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $stateEnum = (string)$input->getArgument('state_enum'); + + if (!(enum_exists($stateEnum) && is_subclass_of($stateEnum, State::class))) { + $io->error('The state enum "' . $stateEnum . '" does not exist.'); + + return self::FAILURE; + } + + $format = (string)$input->getArgument('format'); + switch ($format) { + case self::FORMAT_MERMAID: + /** @psalm-suppress ArgumentTypeCoercion Type is enforced upper but not detected by psalm */ + $output->writeln((new MermaidDumper)->dump($stateEnum)); + + break; + default: + $output->writeln('Unknown format "' . $format . '". Supported formats are: ' . implode(', ', self::FORMATS)); + + return self::FAILURE; + } + + return self::SUCCESS; + } +} diff --git a/src/Transition/Transition.php b/src/Transition/Transition.php index 31f86ae..b74a2c2 100644 --- a/src/Transition/Transition.php +++ b/src/Transition/Transition.php @@ -14,9 +14,9 @@ class Transition implements TransitionInterface { public function __construct( public readonly string $name, - /** @var State[] */ + /** @var array */ public readonly array $sourceStates, - public readonly State $targetState, + public readonly State&\BackedEnum $targetState, /** @var array */ public readonly array $properties = [] ) @@ -28,7 +28,7 @@ public function getSourceStates(): array return $this->sourceStates; } - public function getTargetState(): State + public function getTargetState(): State&\BackedEnum { return $this->targetState; } diff --git a/src/Transition/TransitionInterface.php b/src/Transition/TransitionInterface.php index 9ee0d7b..eea9fd3 100644 --- a/src/Transition/TransitionInterface.php +++ b/src/Transition/TransitionInterface.php @@ -12,11 +12,11 @@ interface TransitionInterface { /** - * @return State[] + * @return array */ public function getSourceStates(): array; - public function getTargetState(): State; + public function getTargetState(): State&\BackedEnum; public function process(object $object): void; diff --git a/tests/Dumper/MermaidDumperTest.php b/tests/Dumper/MermaidDumperTest.php new file mode 100644 index 0000000..d531b5e --- /dev/null +++ b/tests/Dumper/MermaidDumperTest.php @@ -0,0 +1,29 @@ +assertSame( + << published: publish + reported --> published: clear + disabled --> published: clear + published --> reported: report + reported --> disabled: disable + published --> disabled: disable + MERMAID, + (new MermaidDumper)->dump(SimpleArticleState::class) + ); + } +} diff --git a/tests/Extension/Symfony/Command/DumpStateMachineCommandTest.php b/tests/Extension/Symfony/Command/DumpStateMachineCommandTest.php new file mode 100644 index 0000000..06ce63a --- /dev/null +++ b/tests/Extension/Symfony/Command/DumpStateMachineCommandTest.php @@ -0,0 +1,59 @@ +find('finite:state-machine:dump'); + $this->commandTester = new CommandTester($command); + } + + public function test_it_returns_mermaid_dump(): void + { + $this->commandTester->execute([ + 'state_enum' => DocumentState::class, + 'format' => 'mermaid', + ]); + + $this->commandTester->assertCommandIsSuccessful(); + } + + public function test_it_fails_with_unknown_state_enum(): void + { + $this->commandTester->execute([ + 'state_enum' => 'UnknownStateEnum', + 'format' => 'mermaid', + ]); + + $this->assertSame(1, $this->commandTester->getStatusCode()); + } + + public function test_it_fails_with_unknown_format(): void + { + $this->commandTester->execute([ + 'state_enum' => DocumentState::class, + 'format' => 'blobfish', + ]); + + $this->assertSame(1, $this->commandTester->getStatusCode()); + } + + protected static function getKernelClass(): string + { + return \AppKernel::class; + } +} diff --git a/tests/Transition/TransitionTest.php b/tests/Transition/TransitionTest.php index 91405fa..59e964f 100644 --- a/tests/Transition/TransitionTest.php +++ b/tests/Transition/TransitionTest.php @@ -4,6 +4,7 @@ namespace Finite\Tests\Transition; use Finite\State; +use Finite\Tests\E2E\SimpleArticleState; use Finite\Transition\Transition; use PHPUnit\Framework\TestCase; @@ -13,12 +14,10 @@ class TransitionTest extends TestCase protected function setUp(): void { - $targetState = $this->createMock(State::class); - $this->object = new Transition( 'name', - ['source'], - $targetState, + [SimpleArticleState::DRAFT], + SimpleArticleState::PUBLISHED, ['property' => 'value', 'property2' => 'value2'], ); }