diff --git a/.gitignore b/.gitignore index f02a2f8..1bce9c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.idea build composer.lock docs diff --git a/.phpunit.result.cache b/.phpunit.result.cache deleted file mode 100644 index 90564f5..0000000 --- a/.phpunit.result.cache +++ /dev/null @@ -1 +0,0 @@ -C:37:"PHPUnit\Runner\DefaultTestResultCache":4858:{a:2:{s:7:"defects";a:3:{s:65:"Sebdesign\SM\Test\Commands\DebugTest::it_accepts_a_graph_argument";i:6;s:57:"Sebdesign\SM\Test\Commands\DebugTest::it_asks_for_a_graph";i:6;s:63:"Sebdesign\SM\Test\Factory\FactoryTest::it_normalizes_the_states";i:6;}s:5:"times";a:51:{s:106:"Sebdesign\SM\Test\Callback\ContainerAwareCallbackFactoryTest::it_implements_the_callback_factory_interface";d:0.271;s:86:"Sebdesign\SM\Test\Callback\ContainerAwareCallbackFactoryTest::it_accepts_the_container";d:0.177;s:101:"Sebdesign\SM\Test\Callback\ContainerAwareCallbackFactoryTest::it_throws_an_exception_on_invalid_specs";d:0.179;s:88:"Sebdesign\SM\Test\Callback\ContainerAwareCallbackFactoryTest::it_creates_a_gate_callback";d:0.181;s:91:"Sebdesign\SM\Test\Callback\ContainerAwareCallbackTest::it_implements_the_callback_interface";d:0.177;s:79:"Sebdesign\SM\Test\Callback\ContainerAwareCallbackTest::it_accepts_the_container";d:0.181;s:94:"Sebdesign\SM\Test\Callback\ContainerAwareCallbackTest::it_resolves_services_from_the_container";d:0.234;s:95:"Sebdesign\SM\Test\Callback\ContainerAwareCallbackTest::it_accepts_callable_strings_with_at_sign";d:0.213;s:82:"Sebdesign\SM\Test\Callback\ContainerAwareCallbackTest::it_calls_methods_statically";d:0.194;s:81:"Sebdesign\SM\Test\Callback\GateCallbackTest::it_implements_the_callback_interface";d:0.187;s:65:"Sebdesign\SM\Test\Callback\GateCallbackTest::it_accepts_the_specs";d:0.185;s:64:"Sebdesign\SM\Test\Callback\GateCallbackTest::it_accepts_the_gate";d:0.187;s:63:"Sebdesign\SM\Test\Callback\GateCallbackTest::it_checks_the_gate";d:0.218;s:85:"Sebdesign\SM\Test\Callback\GateCallbackTest::it_creates_a_callback_that_uses_the_gate";d:0.213;s:65:"Sebdesign\SM\Test\Commands\DebugTest::it_accepts_a_graph_argument";d:0.643;s:57:"Sebdesign\SM\Test\Commands\DebugTest::it_asks_for_a_graph";d:0.68;s:87:"Sebdesign\SM\Test\Commands\DebugTest::it_returns_an_error_if_the_configuration_is_empty";d:0.549;s:83:"Sebdesign\SM\Test\Commands\DebugTest::it_returns_an_error_if_the_graph_is_not_found";d:0.457;s:62:"Sebdesign\SM\Test\Event\DispatcherTest::it_dispatches_an_event";d:0.287;s:58:"Sebdesign\SM\Test\Event\DispatcherTest::it_adds_a_listener";d:0.193;s:60:"Sebdesign\SM\Test\Event\DispatcherTest::it_adds_a_subscriber";d:0.194;s:61:"Sebdesign\SM\Test\Event\DispatcherTest::it_removes_a_listener";d:0.219;s:63:"Sebdesign\SM\Test\Event\DispatcherTest::it_removes_a_subscriber";d:0.194;s:61:"Sebdesign\SM\Test\Event\DispatcherTest::it_gets_the_listeners";d:0.198;s:69:"Sebdesign\SM\Test\Event\DispatcherTest::it_gets_the_listener_priority";d:0.213;s:69:"Sebdesign\SM\Test\Event\DispatcherTest::it_checks_if_it_has_listeners";d:0.198;s:71:"Sebdesign\SM\Test\Event\DispatcherTest::it_dispatches_transition_events";d:0.237;s:53:"Sebdesign\SM\Test\FacadeTest::it_provides_the_factory";d:0.247;s:72:"Sebdesign\SM\Test\Factory\FactoryTest::it_gets_the_default_state_machine";d:0.198;s:71:"Sebdesign\SM\Test\Factory\FactoryTest::it_gets_a_specific_state_machine";d:0.199;s:89:"Sebdesign\SM\Test\Factory\FactoryTest::it_fails_when_the_state_machine_class_doesnt_exist";d:0.195;s:63:"Sebdesign\SM\Test\Factory\FactoryTest::it_normalizes_the_states";d:0.197;s:72:"Sebdesign\SM\Test\Metadata\MetadataStoreTest::it_gets_the_graph_metadata";d:0.196;s:72:"Sebdesign\SM\Test\Metadata\MetadataStoreTest::it_gets_the_state_metadata";d:0.225;s:77:"Sebdesign\SM\Test\Metadata\MetadataStoreTest::it_gets_the_transition_metadata";d:0.196;s:69:"Sebdesign\SM\Test\ServiceProviderTest::the_configuration_is_published";d:0.191;s:54:"Sebdesign\SM\Test\ServiceProviderTest::is_not_deferred";d:0.192;s:73:"Sebdesign\SM\Test\ServiceProviderTest::the_callback_factory_is_registered";d:0.191;s:73:"Sebdesign\SM\Test\ServiceProviderTest::the_event_dispatcher_is_registered";d:0.197;s:84:"Sebdesign\SM\Test\ServiceProviderTest::the_cascade_transition_callback_is_registered";d:0.202;s:64:"Sebdesign\SM\Test\ServiceProviderTest::the_factory_is_registered";d:0.199;s:70:"Sebdesign\SM\Test\ServiceProviderTest::the_debug_command_is_registered";d:0.196;s:62:"Sebdesign\SM\Test\ServiceProviderTest::it_provides_the_factory";d:0.197;s:68:"Sebdesign\SM\Test\ServiceProviderTest::it_provides_the_debug_command";d:0.193;s:66:"Sebdesign\SM\Test\StateMachine\StateMachineTest::it_sets_the_state";d:0.198;s:77:"Sebdesign\SM\Test\StateMachine\StateMachineTest::it_cant_set_an_invalid_state";d:0.202;s:75:"Sebdesign\SM\Test\StateMachine\StateMachineTest::it_gets_the_metadata_store";d:0.198;s:80:"Sebdesign\SM\Test\StateMachine\StateMachineTest::it_gets_metadata_from_the_graph";d:0.201;s:78:"Sebdesign\SM\Test\StateMachine\StateMachineTest::it_gets_metadata_from_a_state";d:0.204;s:88:"Sebdesign\SM\Test\StateMachine\StateMachineTest::it_gets_metadata_from_the_current_state";d:0.2;s:83:"Sebdesign\SM\Test\StateMachine\StateMachineTest::it_gets_metadata_from_a_transition";d:0.2;}}} \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 1411779..d73d0ce 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,10 @@ php: - 7.3 - 7.4snapshot ++before_install: + - sudo apt-get update + - sudo apt-get -y install graphviz + before_script: - travis_retry composer self-update - travis_retry composer install --no-interaction --prefer-source diff --git a/README.md b/README.md index fd24de3..0a56fda 100644 --- a/README.md +++ b/README.md @@ -468,6 +468,19 @@ $ php artisan winzou:state-machine:debug simple +----------------------+--------------+------------------------------+---------------+ ``` +## Visualize command + +An artisan command for generating an image of a given graph is included. It accepts the name of the graph as an argument. +It's taken from the corresponding bundle for Symfony: [https://github.com/MadMind/StateMachineVisualizationBundle](https://github.com/MadMind/StateMachineVisualizationBundle), so all credits goes to the original author. + +If you want to run this command, you need to have installed **dot** - Part of graphviz package ([http://www.graphviz.org/](http://www.graphviz.org/)). In your mac, this is equal to having run ```brew install graphviz``` + +```bash +php artisan winzou:state-machine:visualize {graph? : A state machine graph} {--output=./graph.jpg} {--format=jpg} {--direction=TB} {--shape=circle} {--dot-path=/usr/local/bin/dot} +``` + +![test](https://user-images.githubusercontent.com/1104083/75524206-bcfd1a00-5a0d-11ea-9dce-aa0d61e46e75.jpg) + ## Statable trait for Eloquent models If you want to interact with the state machine directly within your models, you can install the [laravel-statable](https://github.com/iben12/laravel-statable) package by [iben12](https://github.com/iben12). diff --git a/src/Commands/Visualize.php b/src/Commands/Visualize.php new file mode 100644 index 0000000..c56615b --- /dev/null +++ b/src/Commands/Visualize.php @@ -0,0 +1,151 @@ +config = $config; + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + if (empty($this->config)) { + $this->error('There are no state machines configured.'); + + return 1; + } + + if (! $this->argument('graph')) { + $this->askForGraph(); + } + + $graph = $this->argument('graph'); + + if (! array_key_exists($graph, $this->config)) { + $this->error('The provided state machine graph is not configured.'); + + return 1; + } + + $config = $this->config[$graph]; + + $this->stateMachineInDotFormat($config); + + return 0; + } + + /** + * Ask for a graph name if one was not provided as argument. + */ + protected function askForGraph() + { + $choices = array_map(function ($name, $config) { + return $name."\t(".$config['class'].' - '.$config['graph'].')'; + }, array_keys($this->config), $this->config); + + $choice = $this->choice('Which state machine would you like to know about?', $choices, 0); + + $choice = substr($choice, 0, strpos($choice, "\t")); + + $this->info('You have just selected: '.$choice); + + $this->input->setArgument('graph', $choice); + } + + protected function stateMachineInDotFormat(array $config) + { + // Output image mime types. + $mimeTypes = [ + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'gif' => 'image/gif', + 'svg' => 'image/svg+xml', + ]; + + $format = $this->option('format'); + + if (empty($mimeTypes[$format])) { + throw new \Exception(sprintf("Format '%s' is not supported", $format)); + } + + $dotPath = $this->option('dot-path') ?? 'dot'; + $outputImage = $this->option('output'); + + $process = new Process([$dotPath, '-T', $format, '-o', $outputImage]); + $process->setInput($this->buildDotFile($config)); + $process->run(); + + // executes after the command finishes + if (! $process->isSuccessful()) { + throw new ProcessFailedException($process); + } + } + + protected function buildDotFile(array $config): string + { + // Display settings + $layout = $this->option('direction') === 'TB' ? 'TB' : 'LR'; + $nodeShape = $this->option('shape'); + + // Build dot file content. + $result = []; + $result[] = 'digraph finite_state_machine {'; + $result[] = "rankdir={$layout};"; + $result[] = 'node [shape = point]; _start_'; // Input node + + // Use first value from 'states' as start. + $start = $config['states'][0]['name']; + $result[] = "node [shape = {$nodeShape}];"; // Default nodes + $result[] = "_start_ -> \"{$start}\";"; // Input node -> starting node. + + foreach ($config['transitions'] as $name => $transition) { + foreach ($transition['from'] as $from) { + $result[] = "\"{$from}\" -> \"{$transition['to']}\" [label = \"{$name}\"];"; + } + } + + $result[] = '}'; + + return implode(PHP_EOL, $result); + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index f35b943..658409c 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -8,6 +8,7 @@ use Sebdesign\SM\Callback\ContainerAwareCallback; use Sebdesign\SM\Callback\ContainerAwareCallbackFactory; use Sebdesign\SM\Commands\Debug; +use Sebdesign\SM\Commands\Visualize; use Sebdesign\SM\Event\Dispatcher; use Sebdesign\SM\Factory\Factory; use SM\Callback\CallbackFactoryInterface; @@ -32,8 +33,8 @@ public function boot() if ($this->app->runningInConsole()) { if ($this->app instanceof LaravelApplication) { $this->publishes([ - __DIR__.'/../config/state-machine.php' => config_path('state-machine.php'), - ], 'config'); + __DIR__.'/../config/state-machine.php' => config_path('state-machine.php'), + ], 'config'); } elseif ($this->app instanceof LumenApplication) { $this->app->configure('state-machine'); } @@ -96,8 +97,13 @@ protected function registerCommands() return new Debug($app->make('config')->get('state-machine', [])); }); + $this->app->bind(Visualize::class, function ($app) { + return new Visualize($app->make('config')->get('state-machine', [])); + }); + $this->commands([ Debug::class, + Visualize::class, ]); } diff --git a/tests/Commands/VisualizeTest.php b/tests/Commands/VisualizeTest.php new file mode 100644 index 0000000..04a8823 --- /dev/null +++ b/tests/Commands/VisualizeTest.php @@ -0,0 +1,43 @@ +markTestSkipped('Dot executable not found.'); + } + + // Arrange + + $config = $this->app['config']->get('state-machine', []); + $command = \Mockery::spy('\Sebdesign\SM\Commands\Visualize[choice]', [$config]); + + $this->app[Kernel::class]->registerCommand($command); + + // Act + + $outputImage = tempnam(sys_get_temp_dir(), 'smv'); + $this->artisan('winzou:state-machine:visualize', [ + 'graph' => 'graphA', + '--no-interaction' => true, + '--output' => $outputImage, + ]); + + // Assert + $this->withSuccessCode(); + + $this->assertTrue(file_exists($outputImage)); + } +}