From ff16d7391b7988367231d9be762f2b72c6263265 Mon Sep 17 00:00:00 2001 From: Jared Pearson Date: Tue, 1 Jun 2021 15:55:01 -0700 Subject: [PATCH] Add action and args state validation --- src/BGAWorkbench/Validate/StateValidator.php | 51 ++++++ .../Tests/Validate/StateValidatorTest.php | 164 ++++++++++++++++-- 2 files changed, 202 insertions(+), 13 deletions(-) diff --git a/src/BGAWorkbench/Validate/StateValidator.php b/src/BGAWorkbench/Validate/StateValidator.php index fb50cdd..d58fdb6 100644 --- a/src/BGAWorkbench/Validate/StateValidator.php +++ b/src/BGAWorkbench/Validate/StateValidator.php @@ -2,6 +2,8 @@ namespace BGAWorkbench\Validate; use BGAWorkbench\Project\Project; +use ReflectionClass; +use ReflectionException; use RuntimeException; use Symfony\Component\Config\Definition\Processor; use Functional as F; @@ -52,6 +54,7 @@ function (array $state) use ($stateIds) { $this->validateGameSetupState($states); $this->validateGameEndState($states); $this->validateGameSetupToEnd($states); + $this->validateActionAndArgsMethodExist($states, $project); } private function validateGameSetupState(array $states) @@ -193,6 +196,54 @@ private function visitState(int $stateId, array $state, array $states, array &$v return false; } + /** + * Verifies that every method defined in the state's "action" parameter has a corresponding action method in the + * class that extends "Table". + * + * @param array $states + * @param Project $project + */ + private function validateActionAndArgsMethodExist(array $states, Project $project): void + { + $tableObj = $project->createGameTableInstance(); + $reflectionTableObj = new ReflectionClass($tableObj); + + foreach ($states as $stateId => $state) { + if ($stateId === self::GAME_SETUP_ID || $stateId === self::GAME_END_ID) { + // Skip the gameSetup and gameEnd states + continue; + } + + if (array_key_exists('action', $state)) { + try { + $reflectionActionMethod = $reflectionTableObj->getMethod($state['action']); + } catch (ReflectionException $exception) { + throw new RuntimeException("Action {$state['action']} defined in state {$stateId} is not defined in Table. Ensure that the method is defined in {$project->getName()}.game.php."); + } + if (!$reflectionActionMethod->isPublic()) { + throw new RuntimeException("Action {$state['action']} defined in state {$stateId} must be public. Ensure that the method is defined as 'public' in {$project->getName()}.game.php."); + } + if ($reflectionActionMethod->isAbstract()) { + throw new RuntimeException("Action {$state['action']} defined in state {$stateId} cannot be abstract. Remove the 'abstract' modifier from the method in {$project->getName()}.game.php."); + } + } + + if (array_key_exists('args', $state)) { + try { + $reflectionActionMethod = $reflectionTableObj->getMethod($state['args']); + } catch (ReflectionException $exception) { + throw new RuntimeException("Arguments {$state['args']} defined in state {$stateId} is not defined in Table. Ensure that the method is defined in {$project->getName()}.game.php."); + } + if (!$reflectionActionMethod->isPublic()) { + throw new RuntimeException("Arguments {$state['args']} defined in state {$stateId} must be public. Ensure that the method is defined as 'public' in {$project->getName()}.game.php."); + } + if ($reflectionActionMethod->isAbstract()) { + throw new RuntimeException("Arguments {$state['args']} defined in state {$stateId} cannot be abstract. Remove the 'abstract' modifier from the method in {$project->getName()}.game.php."); + } + } + } + } + private function getGameSetupStateStateExample(): string { return << "", "type" => "game", "action" => "stBar", - "transitions" => array("repeat" => 10, "continue" => 99) + "transitions" => array("repeat" => 10, "continue" => 12, "end" => 99) + ), + 12 => array( + "name" => "caz", + "description" => "", + "type" => "activeplayer", + "args" => "argCaz", + "transitions" => array("continue" => 99) ), 99 => array( "name" => "gameEnd", @@ -49,7 +56,7 @@ public function testValid(): void ); END; - $projectDirectory = $this->createTestProject($statesIncPhpContents); + $projectDirectory = $this->createTestProject('valid', $statesIncPhpContents); $project = new Project($projectDirectory->getFileInfo(), 'valid'); (new StateValidator())->validateStates($project); } @@ -79,8 +86,8 @@ public function testMissingGameSetupState(): void ) ); END; - $projectDirectory = $this->createTestProject($statesIncPhpContents); - $project = new Project($projectDirectory->getFileInfo(), 'missing-game-setup'); + $projectDirectory = $this->createTestProject('missinggamesetup', $statesIncPhpContents); + $project = new Project($projectDirectory->getFileInfo(), 'missinggamesetup'); (new StateValidator())->validateStates($project); } @@ -110,8 +117,8 @@ public function testMissingGameEnd(): void ); END; - $projectDirectory = $this->createTestProject($statesIncPhpContents); - $project = new Project($projectDirectory->getFileInfo(), 'missing-game-end'); + $projectDirectory = $this->createTestProject('missinggameend', $statesIncPhpContents); + $project = new Project($projectDirectory->getFileInfo(), 'missinggameend'); (new StateValidator())->validateStates($project); } @@ -150,8 +157,8 @@ public function testInvalidStateInTransition(): void ); END; - $projectDirectory = $this->createTestProject($statesIncPhpContents); - $project = new Project($projectDirectory->getFileInfo(), 'looping'); + $projectDirectory = $this->createTestProject('invalidstate', $statesIncPhpContents); + $project = new Project($projectDirectory->getFileInfo(), 'invalidstate'); (new StateValidator())->validateStates($project); } @@ -197,8 +204,8 @@ public function testGameSetupDoesNotReachGameEnd(): void ); END; - $projectDirectory = $this->createTestProject($statesIncPhpContents); - $project = new Project($projectDirectory->getFileInfo(), 'looping'); + $projectDirectory = $this->createTestProject('doesnotend', $statesIncPhpContents); + $project = new Project($projectDirectory->getFileInfo(), 'doesnotend'); (new StateValidator())->validateStates($project); } @@ -244,18 +251,149 @@ public function testStateLoop(): void ); END; - $projectDirectory = $this->createTestProject($statesIncPhpContents); + $projectDirectory = $this->createTestProject('looping', $statesIncPhpContents); $project = new Project($projectDirectory->getFileInfo(), 'looping'); (new StateValidator())->validateStates($project); } - private function createTestProject(string $statesIncPhpContents): WorkingDirectory + /** + * Verifies that an exception is thrown when state defines an unknown function for "action" property. + */ + public function testUnknownMethodInAction(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Action stUnknown defined in state 11 is not defined in Table.'); + + $statesIncPhpContents = << array( + "name" => "gameSetup", + "description" => "", + "type" => "manager", + "action" => "stGameSetup", + "transitions" => array("" => 10) + ), + 10 => array( + "name" => "foo", + "description" => "", + "type" => "game", + "action" => "stFoo", + "transitions" => array("continue" => 11) + ), + 11 => array( + "name" => "unknown", + "description" => "", + "type" => "game", + "action" => "stUnknown", // intentionally set to an unknown method + "transitions" => array("continue" => 99) + ), + 99 => array( + "name" => "gameEnd", + "description" => "End of game", + "type" => "manager", + "action" => "stGameEnd", + "args" => "argGameEnd" + ) +); +END; + + $projectDirectory = $this->createTestProject('unknownaction', $statesIncPhpContents); + $project = new Project($projectDirectory->getFileInfo(), 'unknownaction'); + (new StateValidator())->validateStates($project); + } + + /** + * Verifies that an exception is thrown when state defines an unknown function for "args" property. + */ + public function testUnknownMethodInArgs(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Arguments argFoo defined in state 10 is not defined in Table.'); + + $statesIncPhpContents = << array( + "name" => "gameSetup", + "description" => "", + "type" => "manager", + "action" => "stGameSetup", + "transitions" => array("" => 10) + ), + 10 => array( + "name" => "foo", + "description" => "", + "type" => "activeplayer", + "args" => "argFoo", + "transitions" => array("continue" => 99) + ), + 99 => array( + "name" => "gameEnd", + "description" => "End of game", + "type" => "manager", + "action" => "stGameEnd", + "args" => "argGameEnd" + ) +); +END; + + $projectDirectory = $this->createTestProject('unknownargs', $statesIncPhpContents); + $project = new Project($projectDirectory->getFileInfo(), 'unknownargs'); + (new StateValidator())->validateStates($project); + } + + private function createTestProject(string $projectName, string $statesIncPhpContents, ?string $gamePhpContents = null): WorkingDirectory { $workingDir = WorkingDirectory::createTemp(); if (!file_put_contents(join(DIRECTORY_SEPARATOR, [$workingDir->getPathname(), 'states.inc.php']), $statesIncPhpContents)) { - throw new \RuntimeException("Failed to write states.inc.php"); + throw new RuntimeException("Failed to write states.inc.php"); + } + + if (is_null($gamePhpContents)) { + $gamePhpContents = $this->createValidGamePhpContents($projectName); + } + if (!file_put_contents(join(DIRECTORY_SEPARATOR, [$workingDir->getPathname(), $projectName . '.game.php']), $gamePhpContents)) { + throw new RuntimeException("Failed to write {$projectName}.game.php"); } return $workingDir; } + + private function createValidGamePhpContents(string $projectName): string + { + return <<