├── .dockerignore ├── .github ├── actions │ └── setup │ │ └── action.yml └── workflows │ ├── static-analysis.yml │ └── tests.yml ├── .gitignore ├── .phan.config.php ├── CHANGELOG.md ├── CONTRIBUTORS.md ├── Dockerfile ├── LICENSE ├── README.md ├── benchmark ├── BenchmarkCommand.php ├── BenchmarkRunner.php ├── InputValidator.php ├── ProgressBarInterface.php ├── Result │ ├── AggregatedResult.php │ ├── Result.php │ ├── ResultAggregator.php │ └── ResultPrinter.php ├── SymfonyProgressBar.php ├── TerrainGenerator.php └── benchmark.php ├── composer.json ├── examples ├── Graph │ ├── Coordinate.php │ ├── DomainLogic.php │ ├── Graph.php │ ├── Link.php │ ├── SequencePrinter.php │ └── example.php └── Terrain │ ├── DomainLogic.php │ ├── Position.php │ ├── SequencePrinter.php │ ├── StaticExample.php │ ├── TerrainCost.php │ └── example.php ├── phpstan.neon.dist ├── phpunit.xml.dist ├── psalm.xml ├── src ├── AStar.php ├── DomainLogicInterface.php └── Node │ ├── Collection │ ├── NodeCollectionInterface.php │ └── NodeHashTable.php │ ├── Node.php │ └── NodeIdentifierInterface.php └── tests ├── AStarTest.php ├── Benchmark ├── BenchmarkCommandTest.php ├── BenchmarkRunnerTest.php ├── InputValidatorTest.php ├── Result │ ├── AggregatedResultTest.php │ ├── ResultAggregatorTest.php │ ├── ResultPrinterTest.php │ └── ResultTest.php ├── SymfonyProgressBarTest.php └── TerrainGeneratorTest.php ├── Example ├── Graph │ ├── CoordinateTest.php │ ├── DomainLogicTest.php │ ├── GraphTest.php │ ├── LinkTest.php │ └── SequencePrinterTest.php └── Terrain │ ├── DomainLogicTest.php │ ├── PositionTest.php │ ├── SequencePrinterTest.php │ ├── StaticExampleTest.php │ └── TerrainCostTest.php └── Node ├── Collection └── NodeHashTableTest.php └── NodeTest.php /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | vendor 3 | build 4 | composer.lock 5 | .phpunit.result.cache 6 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup PHP and install dependencies 2 | description: Install the right version of PHP (according to strategy.matrix.php) as well as the project depencencies (via Composer) 3 | runs: 4 | using: composite 5 | steps: 6 | - name: Setup PHP 7 | uses: shivammathur/setup-php@v2 8 | with: 9 | php-version: ${{ matrix.php }} 10 | 11 | - name: Install dependencies 12 | run: composer install --no-progress 13 | shell: sh 14 | -------------------------------------------------------------------------------- /.github/workflows/static-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Static Analysis 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | composer-validation: 7 | name: Composer Validation 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout the repository 11 | uses: actions/checkout@v2 12 | 13 | - name: Validate composer.json 14 | run: composer validate --strict 15 | 16 | coding-standards: 17 | name: Coding Standards 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout the repository 21 | uses: actions/checkout@v2 22 | 23 | - name: Install dependencies 24 | run: composer install --no-progress 25 | 26 | - name: Check coding standards 27 | run: composer coding-standards 28 | 29 | phan: 30 | name: Phan 31 | runs-on: ubuntu-latest 32 | strategy: 33 | matrix: 34 | php: 35 | - '8.0' 36 | - '8.1' 37 | steps: 38 | - name: Checkout the repository 39 | uses: actions/checkout@v2 40 | 41 | - name: Setup PHP and install dependencies 42 | uses: ./.github/actions/setup 43 | 44 | - name: Run Phan 45 | run: composer static-analysis:phan -- --no-progress-bar --target-php-version=${{ matrix.php }} --minimum-target-php-version=${{ matrix.php }} 46 | 47 | phpstan: 48 | name: PHPStan 49 | runs-on: ubuntu-latest 50 | strategy: 51 | matrix: 52 | php: 53 | - '8.0' 54 | - '8.1' 55 | steps: 56 | - name: Checkout the repository 57 | uses: actions/checkout@v2 58 | 59 | - name: Setup PHP and install dependencies 60 | uses: ./.github/actions/setup 61 | 62 | - name: Run PHPStan 63 | run: composer static-analysis:phpstan -- --no-progress 64 | 65 | psalm: 66 | name: Psalm 67 | runs-on: ubuntu-latest 68 | strategy: 69 | matrix: 70 | php: 71 | - '8.0' 72 | - '8.1' 73 | steps: 74 | - name: Checkout the repository 75 | uses: actions/checkout@v2 76 | 77 | - name: Setup PHP and install dependencies 78 | uses: ./.github/actions/setup 79 | 80 | - name: Run Psalm 81 | run: composer static-analysis:psalm -- --no-progress --php-version=${{ matrix.php }} 82 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | unit-tests: 7 | name: Unit Tests 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | php: 12 | - '8.0' 13 | - '8.1' 14 | steps: 15 | - name: Checkout the repository 16 | uses: actions/checkout@v2 17 | 18 | - name: Setup PHP and install dependencies 19 | uses: ./.github/actions/setup 20 | 21 | - name: Run test suite 22 | run: composer test 23 | 24 | - name: Upload coverage results to Coveralls 25 | env: 26 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | run: php vendor/bin/php-coveralls -v 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /build/ 3 | composer.lock 4 | .phpunit.result.cache 5 | -------------------------------------------------------------------------------- /.phan.config.php: -------------------------------------------------------------------------------- 1 | =8.0" 53 | 'target_php_version' => null, 54 | 55 | // If enabled, missing properties will be created when 56 | // they are first seen. If false, we'll report an 57 | // error message if there is an attempt to write 58 | // to a class property that wasn't explicitly 59 | // defined. 60 | 'allow_missing_properties' => false, 61 | 62 | // If enabled, null can be cast to any type and any 63 | // type can be cast to null. Setting this to true 64 | // will cut down on false positives. 65 | 'null_casts_as_any_type' => false, 66 | 67 | // If enabled, allow null to be cast as any array-like type. 68 | // 69 | // This is an incremental step in migrating away from `null_casts_as_any_type`. 70 | // If `null_casts_as_any_type` is true, this has no effect. 71 | 'null_casts_as_array' => false, 72 | 73 | // If enabled, allow any array-like type to be cast to null. 74 | // This is an incremental step in migrating away from `null_casts_as_any_type`. 75 | // If `null_casts_as_any_type` is true, this has no effect. 76 | 'array_casts_as_null' => false, 77 | 78 | // If enabled, scalars (int, float, bool, string, null) 79 | // are treated as if they can cast to each other. 80 | // This does not affect checks of array keys. See `scalar_array_key_cast`. 81 | 'scalar_implicit_cast' => false, 82 | 83 | // If enabled, any scalar array keys (int, string) 84 | // are treated as if they can cast to each other. 85 | // E.g. `array` can cast to `array` and vice versa. 86 | // Normally, a scalar type such as int could only cast to/from int and mixed. 87 | 'scalar_array_key_cast' => false, 88 | 89 | // If this has entries, scalars (int, float, bool, string, null) 90 | // are allowed to perform the casts listed. 91 | // 92 | // E.g. `['int' => ['float', 'string'], 'float' => ['int'], 'string' => ['int'], 'null' => ['string']]` 93 | // allows casting null to a string, but not vice versa. 94 | // (subset of `scalar_implicit_cast`) 95 | 'scalar_implicit_partial' => [], 96 | 97 | // If enabled, Phan will warn if **any** type in a method invocation's object 98 | // is definitely not an object, 99 | // or if **any** type in an invoked expression is not a callable. 100 | // Setting this to true will introduce numerous false positives 101 | // (and reveal some bugs). 102 | 'strict_method_checking' => true, 103 | 104 | // If enabled, Phan will warn if **any** type of the object expression for a property access 105 | // does not contain that property. 106 | 'strict_object_checking' => true, 107 | 108 | // If enabled, Phan will warn if **any** type in the argument's union type 109 | // cannot be cast to a type in the parameter's expected union type. 110 | // Setting this to true will introduce numerous false positives 111 | // (and reveal some bugs). 112 | 'strict_param_checking' => true, 113 | 114 | // If enabled, Phan will warn if **any** type in a property assignment's union type 115 | // cannot be cast to a type in the property's declared union type. 116 | // Setting this to true will introduce numerous false positives 117 | // (and reveal some bugs). 118 | 'strict_property_checking' => true, 119 | 120 | // If enabled, Phan will warn if **any** type in a returned value's union type 121 | // cannot be cast to the declared return type. 122 | // Setting this to true will introduce numerous false positives 123 | // (and reveal some bugs). 124 | 'strict_return_checking' => true, 125 | 126 | // If true, seemingly undeclared variables in the global 127 | // scope will be ignored. 128 | // 129 | // This is useful for projects with complicated cross-file 130 | // globals that you have no hope of fixing. 131 | 'ignore_undeclared_variables_in_global_scope' => false, 132 | 133 | // Set this to false to emit `PhanUndeclaredFunction` issues for internal functions that Phan has signatures for, 134 | // but aren't available in the codebase, or from Reflection. 135 | // (may lead to false positives if an extension isn't loaded) 136 | // 137 | // If this is true(default), then Phan will not warn. 138 | // 139 | // Even when this is false, Phan will still infer return values and check parameters of internal functions 140 | // if Phan has the signatures. 141 | 'ignore_undeclared_functions_with_known_signatures' => false, 142 | 143 | // Backwards Compatibility Checking. This is slow 144 | // and expensive, but you should consider running 145 | // it before upgrading your version of PHP to a 146 | // new version that has backward compatibility 147 | // breaks. 148 | // 149 | // If you are migrating from PHP 5 to PHP 7, 150 | // you should also look into using 151 | // [php7cc (no longer maintained)](https://github.com/sstalle/php7cc) 152 | // and [php7mar](https://github.com/Alexia/php7mar), 153 | // which have different backwards compatibility checks. 154 | // 155 | // If you are still using versions of php older than 5.6, 156 | // `PHP53CompatibilityPlugin` may be worth looking into if you are not running 157 | // syntax checks for php 5.3 through another method such as 158 | // `InvokePHPNativeSyntaxCheckPlugin` (see .phan/plugins/README.md). 159 | 'backward_compatibility_checks' => false, 160 | 161 | // If true, check to make sure the return type declared 162 | // in the doc-block (if any) matches the return type 163 | // declared in the method signature. 164 | 'check_docblock_signature_return_type_match' => true, 165 | 166 | // This setting maps case-insensitive strings to union types. 167 | // 168 | // This is useful if a project uses phpdoc that differs from the phpdoc2 standard. 169 | // 170 | // If the corresponding value is the empty string, 171 | // then Phan will ignore that union type (E.g. can ignore 'the' in `@return the value`) 172 | // 173 | // If the corresponding value is not empty, 174 | // then Phan will act as though it saw the corresponding UnionTypes(s) 175 | // when the keys show up in a UnionType of `@param`, `@return`, `@var`, `@property`, etc. 176 | // 177 | // This matches the **entire string**, not parts of the string. 178 | // (E.g. `@return the|null` will still look for a class with the name `the`, but `@return the` will be ignored with the below setting) 179 | // 180 | // (These are not aliases, this setting is ignored outside of doc comments). 181 | // (Phan does not check if classes with these names exist) 182 | // 183 | // Example setting: `['unknown' => '', 'number' => 'int|float', 'char' => 'string', 'long' => 'int', 'the' => '']` 184 | 'phpdoc_type_mapping' => [], 185 | 186 | // Set to true in order to attempt to detect dead 187 | // (unreferenced) code. Keep in mind that the 188 | // results will only be a guess given that classes, 189 | // properties, constants and methods can be referenced 190 | // as variables (like `$class->$property` or 191 | // `$class->$method()`) in ways that we're unable 192 | // to make sense of. 193 | // 194 | // To more aggressively detect dead code, 195 | // you may want to set `dead_code_detection_prefer_false_negative` to `false`. 196 | 'dead_code_detection' => false, 197 | 198 | // Set to true in order to attempt to detect unused variables. 199 | // `dead_code_detection` will also enable unused variable detection. 200 | // 201 | // This has a few known false positives, e.g. for loops or branches. 202 | 'unused_variable_detection' => true, 203 | 204 | // Set to true in order to attempt to detect redundant and impossible conditions. 205 | // 206 | // This has some false positives involving loops, 207 | // variables set in branches of loops, and global variables. 208 | 'redundant_condition_detection' => true, 209 | 210 | // If enabled, Phan will act as though it's certain of real return types of a subset of internal functions, 211 | // even if those return types aren't available in reflection (real types were taken from php 7.3 or 8.0-dev, depending on target_php_version). 212 | // 213 | // Note that with php 7 and earlier, php would return null or false for many internal functions if the argument types or counts were incorrect. 214 | // As a result, enabling this setting with target_php_version 8.0 may result in false positives for `--redundant-condition-detection` when codebases also support php 7.x. 215 | 'assume_real_types_for_internal_functions' => true, 216 | 217 | // If true, this runs a quick version of checks that takes less 218 | // time at the cost of not running as thorough 219 | // of an analysis. You should consider setting this 220 | // to true only when you wish you had more **undiagnosed** issues 221 | // to fix in your code base. 222 | // 223 | // In quick-mode the scanner doesn't rescan a function 224 | // or a method's code block every time a call is seen. 225 | // This means that the problem here won't be detected: 226 | // 227 | // ```php 228 | // false, 247 | 248 | // Override to hardcode existence and types of (non-builtin) globals in the global scope. 249 | // Class names should be prefixed with `\`. 250 | // 251 | // (E.g. `['_FOO' => '\FooClass', 'page' => '\PageClass', 'userId' => 'int']`) 252 | 'globals_type_map' => [], 253 | 254 | // The minimum severity level to report on. This can be 255 | // set to `Issue::SEVERITY_LOW`, `Issue::SEVERITY_NORMAL` or 256 | // `Issue::SEVERITY_CRITICAL`. Setting it to only 257 | // critical issues is a good place to start on a big 258 | // sloppy mature code base. 259 | 'minimum_severity' => Issue::SEVERITY_LOW, 260 | 261 | // Add any issue types (such as `'PhanUndeclaredMethod'`) 262 | // to this list to inhibit them from being reported. 263 | 'suppress_issue_types' => [ 264 | 'PhanAccessMethodInternal', 265 | 'PhanAccessWrongInheritanceCategoryInternal', 266 | 'PhanCompatibleTrailingCommaParameterList', 267 | 'PhanGenericConstructorTypes', 268 | 'PhanParamSignatureMismatch', 269 | 'PhanPossiblyFalseTypeArgumentInternal', 270 | 'PhanPossiblyNonClassMethodCall', 271 | 'PhanTypeMismatchDeclaredParamNullable', 272 | 'PhanUnextractableAnnotationSuffix', 273 | ], 274 | 275 | // A regular expression to match files to be excluded 276 | // from parsing and analysis and will not be read at all. 277 | // 278 | // This is useful for excluding groups of test or example 279 | // directories/files, unanalyzable files, or files that 280 | // can't be removed for whatever reason. 281 | // (e.g. `'@Test\.php$@'`, or `'@vendor/.*/(tests|Tests)/@'`) 282 | 'exclude_file_regex' => '@^vendor/.*/(tests?|Tests?)/@', 283 | 284 | // A list of files that will be excluded from parsing and analysis 285 | // and will not be read at all. 286 | // 287 | // This is useful for excluding hopelessly unanalyzable 288 | // files that can't be removed for whatever reason. 289 | 'exclude_file_list' => [], 290 | 291 | // A directory list that defines files that will be excluded 292 | // from static analysis, but whose class and method 293 | // information should be included. 294 | // 295 | // Generally, you'll want to include the directories for 296 | // third-party code (such as "vendor/") in this list. 297 | // 298 | // n.b.: If you'd like to parse but not analyze 3rd 299 | // party code, directories containing that code 300 | // should be added to the `directory_list` as well as 301 | // to `exclude_analysis_directory_list`. 302 | 'exclude_analysis_directory_list' => [ 303 | 'vendor/', 304 | ], 305 | 306 | // Enable this to enable checks of require/include statements referring to valid paths. 307 | // The settings `include_paths` and `warn_about_relative_include_statement` affect the checks. 308 | 'enable_include_path_checks' => true, 309 | 310 | // The number of processes to fork off during the analysis 311 | // phase. 312 | 'processes' => 1, 313 | 314 | // List of case-insensitive file extensions supported by Phan. 315 | // (e.g. `['php', 'html', 'htm']`) 316 | 'analyzed_file_extensions' => [ 317 | 'php', 318 | ], 319 | 320 | // You can put paths to stubs of internal extensions in this config option. 321 | // If the corresponding extension is **not** loaded, then Phan will use the stubs instead. 322 | // Phan will continue using its detailed type annotations, 323 | // but load the constants, classes, functions, and classes (and their Reflection types) 324 | // from these stub files (doubling as valid php files). 325 | // Use a different extension from php to avoid accidentally loading these. 326 | // The `tools/make_stubs` script can be used to generate your own stubs (compatible with php 7.0+ right now) 327 | // 328 | // (e.g. `['xdebug' => '.phan/internal_stubs/xdebug.phan_php']`) 329 | 'autoload_internal_extension_signatures' => [], 330 | 331 | // A list of plugin files to execute. 332 | // 333 | // Plugins which are bundled with Phan can be added here by providing their name (e.g. `'AlwaysReturnPlugin'`) 334 | // 335 | // Documentation about available bundled plugins can be found [here](https://github.com/phan/phan/tree/v5/.phan/plugins). 336 | // 337 | // Alternately, you can pass in the full path to a PHP file with the plugin's implementation (e.g. `'vendor/phan/phan/.phan/plugins/AlwaysReturnPlugin.php'`) 338 | 'plugins' => [ 339 | 'AlwaysReturnPlugin', 340 | 'DollarDollarPlugin', 341 | 'DuplicateArrayKeyPlugin', 342 | 'DuplicateExpressionPlugin', 343 | 'PregRegexCheckerPlugin', 344 | 'PrintfCheckerPlugin', 345 | 'SleepCheckerPlugin', 346 | 'UnreachableCodePlugin', 347 | 'UseReturnValuePlugin', 348 | 'EmptyStatementListPlugin', 349 | 'StrictComparisonPlugin', 350 | 'LoopVariableReusePlugin', 351 | ], 352 | 353 | // A list of directories that should be parsed for class and 354 | // method information. After excluding the directories 355 | // defined in `exclude_analysis_directory_list`, the remaining 356 | // files will be statically analyzed for errors. 357 | // 358 | // Thus, both first-party and third-party code being used by 359 | // your application should be included in this list. 360 | 'directory_list' => [ 361 | 'src', 362 | 'benchmark', 363 | 'examples', 364 | 'vendor/symfony/console', 365 | 'vendor/symfony/stopwatch', 366 | ], 367 | 368 | // A list of individual files to include in analysis 369 | // with a path relative to the root directory of the 370 | // project. 371 | 'file_list' => [], 372 | ]; 373 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [2.1.1] - 2022-01-02 8 | ### Added 9 | - Added a new static analysis tool: Phan. 10 | - Added a new static analysis tool: PHPStan. 11 | - Added a new static analysis tool: Psalm. 12 | 13 | ### Changed 14 | - Replaced Travis CI with GitHub Actions. 15 | - Marked `NodeCollectionInterface` and `NodeHashTable` as internal. 16 | 17 | ### Fixed 18 | - Fixed `NodeHashTable::getIterator()` return type. 19 | 20 | ## [2.1.0] - 2021-02-09 21 | ### Added 22 | - Added a `NodeIdentifierInterface` to allow the user to specify unique node IDs. 23 | 24 | ## [2.0.0] - 2021-02-08 25 | ### Changed 26 | - Raised the minimum required version of PHP to 8.0. 27 | - Removed support for HHVM. 28 | - Changed the library's public API (**breaking change**). 29 | - Switched the coding standards from PSR-2 to PSR-12. 30 | 31 | ## [1.2.0] - 2021-02-03 32 | ### Added 33 | - Added a composer script for the unit tests and code coverage. 34 | - Added a composer script for the coding standards. 35 | - Added a composer script for the graph example. 36 | - Added a composer script for the terrain example. 37 | - Added Travis build for PHP 7.3. 38 | - Added a benchmark utility. 39 | 40 | ### Changed 41 | - Removed `prestissimo` from the [Dockerfile](Dockerfile). 42 | 43 | ### Fixed 44 | - Fixed Travis build for PHP 5.4 and 5.5. 45 | - Fixed one test that had an ambiguous solution. 46 | 47 | ## [1.1.2] - 2018-06-03 48 | ### Added 49 | - Added a Contributors file. 50 | - Added Scrutinizer support. 51 | - Added Travis builds for PHP 7.0, 7.1 and 7.2. 52 | - Added support for Docker. 53 | 54 | ### Changed 55 | - Enforced the PSR-2 standards by checking them with CodeSniffer during the Travis build. 56 | - Updated the Changelog format. 57 | - Updated the development dependencies in composer. 58 | - Simplified the installation instructions. 59 | 60 | ### Deprecated 61 | - Deprecated the `addChild` and `getChildren` method signatures from the `Node` interface. 62 | 63 | ### Fixed 64 | - Fixed Travis build for PHP 5.3. 65 | - The tests and examples now use the `autoload-dev` section instead of `autoload` in `composer.json`. 66 | - Fixed Coveralls not working due to the `src_dir` parameter being removed in the current version. 67 | 68 | ## [1.1.1] - 2014-10-14 69 | ### Added 70 | - Added a Contributing section to the Readme. 71 | 72 | ### Fixed 73 | - Fixed an infinite loop bug when getting a solution (path) that contains a starting node with a circular reference to its parent. 74 | - Fixed a PHPUnit issue caused by `BaseAStarTest` not being abstract. 75 | 76 | ## [1.1.0] - 2014-06-03 77 | ### Added 78 | - Added a new example (Graph). 79 | - Added Travis support (Continuous Integration server). 80 | - Added Coveralls support. 81 | - Added a required minimum PHP version (5.3.0). 82 | 83 | ### Changed 84 | - Removed an unnecessary step in the algorithm. 85 | - Increased the code coverage. 86 | 87 | ### Fixed 88 | - When the node being evaluated is found in the open or closed list, the algorithm checks its tentative G value (rather than its F value) in order to determine if the node has a better cost. 89 | 90 | ## 1.0.0 - 2014-05-21 91 | ### Added 92 | - Initial release. 93 | 94 | [Unreleased]: https://github.com/jmgq/php-a-star/compare/v2.1.1...HEAD 95 | [2.1.1]: https://github.com/jmgq/php-a-star/compare/v2.1.0...v2.1.1 96 | [2.1.0]: https://github.com/jmgq/php-a-star/compare/v2.0.0...v2.1.0 97 | [2.0.0]: https://github.com/jmgq/php-a-star/compare/v1.2.0...v2.0.0 98 | [1.2.0]: https://github.com/jmgq/php-a-star/compare/v1.1.2...v1.2.0 99 | [1.1.2]: https://github.com/jmgq/php-a-star/compare/v1.1.1...v1.1.2 100 | [1.1.1]: https://github.com/jmgq/php-a-star/compare/v1.1.0...v1.1.1 101 | [1.1.0]: https://github.com/jmgq/php-a-star/compare/v1.0.0...v1.1.0 102 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | Contributors 2 | ============ 3 | Special thanks to those who have helped to improve this project: 4 | - Robin Chauhan ([pathway](https://github.com/pathway)) 5 | - Nikolai Neff ([nineff](https://github.com/nineff)) 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8-cli-alpine 2 | 3 | WORKDIR /opt/php-a-star 4 | 5 | RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \ 6 | && pecl install xdebug \ 7 | && docker-php-ext-enable xdebug \ 8 | && apk del .build-deps 9 | 10 | COPY --from=composer:latest /usr/bin/composer /usr/bin/composer 11 | 12 | COPY composer.json . 13 | 14 | RUN composer install 15 | 16 | COPY . . 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Jose Gonzalez 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A Star algorithm for PHP 2 | ======================== 3 | [![Latest Stable Version](https://poser.pugx.org/jmgq/a-star/v/stable.svg)](https://packagist.org/packages/jmgq/a-star) 4 | [![Static Analysis](https://github.com/jmgq/php-a-star/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/jmgq/php-a-star/actions/workflows/static-analysis.yml) 5 | [![Tests](https://github.com/jmgq/php-a-star/actions/workflows/tests.yml/badge.svg)](https://github.com/jmgq/php-a-star/actions/workflows/tests.yml) 6 | [![Coverage Status](https://coveralls.io/repos/github/jmgq/php-a-star/badge.svg)](https://coveralls.io/github/jmgq/php-a-star) 7 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/jmgq/php-a-star/badges/quality-score.png)](https://scrutinizer-ci.com/g/jmgq/php-a-star) 8 | [![SemVer](https://img.shields.io/:semver-2.0.0-brightgreen.svg)](https://semver.org/spec/v2.0.0.html) 9 | [![License](https://poser.pugx.org/jmgq/a-star/license.svg)](https://packagist.org/packages/jmgq/a-star) 10 | 11 | A Star pathfinding algorithm implementation for PHP. 12 | 13 | Requirements 14 | ------------ 15 | You need PHP >= 8.0 to use this library, but the latest stable version of PHP is recommended. 16 | 17 | If you need to run this library on an older version of PHP (or HHVM), please install a 1.x version. 18 | 19 | Installation 20 | ------------ 21 | 1. Install [composer](https://getcomposer.org/). 22 | 23 | 2. Add the A Star algorithm package to your `composer.json` file and download it: 24 | ```sh 25 | composer require jmgq/a-star 26 | ``` 27 | 28 | Usage 29 | ----- 30 | 1. Create a class that implements `DomainLogicInterface`. The parameters of the three methods in this interface are nodes. A node can be of any type: it could be a string, an integer, an object, etc. You decide the shape of a node, depending on your business logic. You can optionally provide a way to identify your nodes ([check why and how](#specifying-the-unique-node-id)). 31 | ```php 32 | use JMGQ\AStar\DomainLogicInterface; 33 | 34 | class DomainLogic implements DomainLogicInterface 35 | { 36 | // ... 37 | 38 | public function getAdjacentNodes(mixed $node): iterable 39 | { 40 | // Return a collection of adjacent nodes 41 | } 42 | 43 | public function calculateRealCost(mixed $node, mixed $adjacent): float | int 44 | { 45 | // Return the actual cost between two adjacent nodes 46 | } 47 | 48 | public function calculateEstimatedCost(mixed $fromNode, mixed $toNode): float | int 49 | { 50 | // Return the heuristic estimated cost between the two given nodes 51 | } 52 | 53 | // ... 54 | } 55 | ``` 56 | 57 | 2. Instantiate the `AStar` class, which requires the newly created Domain Logic object: 58 | ```php 59 | use JMGQ\AStar\AStar; 60 | 61 | $domainLogic = new DomainLogic(); 62 | 63 | $aStar = new AStar($domainLogic); 64 | ``` 65 | 66 | 3. That's all! You can now use the `run` method in the `AStar` class to generate the best path between two nodes. This method will return an ordered list of nodes, from the start node to the goal node. If there is no solution, an empty list will be returned. 67 | ```php 68 | $solution = $aStar->run($start, $goal); 69 | ``` 70 | 71 | ### Specifying the unique node ID 72 | In order to work correctly, the A* algorithm needs to uniquely identify each node. This library will automatically generate a default ID for each node, which will be the result of serialising the node with PHP's [serialize](https://www.php.net/manual/function.serialize.php) function. This has two major disadvantages: 73 | 1. **It is not always correct**: for instance, let's assume that a node is represented by an associative array with two keys: `x` and `y`. The following two nodes are the same, but their serialised value is not: 74 | ```php 75 | $node1 = ['x' => 4, 'y' => 5]; 76 | $node2 = ['y' => 5, 'x' => 4]; 77 | serialize($node1); // a:2:{s:1:"x";i:4;s:1:"y";i:5;} 78 | serialize($node2); // a:2:{s:1:"y";i:5;s:1:"x";i:4;} 79 | ``` 80 | 2. **Performance issues**: if the node structure is very complex, serialising it could take too long. 81 | 82 | Rather than relying on this default mechanism, you can avoid the serialisation process and instead provide the node ID yourself, by ensuring that your node implements `NodeIdentifierInterface`, which only declares one method: 83 | ```php 84 | interface NodeIdentifierInterface 85 | { 86 | public function getUniqueNodeId(): string; 87 | } 88 | ``` 89 | 90 | For instance, this is how it has been implemented in the Terrain example: 91 | ```php 92 | use JMGQ\AStar\Node\NodeIdentifierInterface; 93 | 94 | class Position implements NodeIdentifierInterface 95 | { 96 | private int $row; 97 | private int $column; 98 | 99 | // ... 100 | 101 | public function getUniqueNodeId(): string 102 | { 103 | return $this->row . 'x' . $this->column; 104 | } 105 | 106 | // ... 107 | } 108 | ``` 109 | 110 | Examples 111 | -------- 112 | There are two working implementations in the [examples](examples) folder. 113 | 114 | ### Terrain Example 115 | In order to execute this example, run the following command: 116 | ```sh 117 | composer example:terrain 118 | ``` 119 | 120 | This example calculates the best route between two tiles in a rectangular board. Each tile has a cost associated to it, represented in a TerrainCost object. Every value in the TerrainCost array indicates the cost of entering into that particular tile. 121 | 122 | For instance, given the following terrain: 123 | ``` 124 | | 0 1 2 3 125 | ----------- 126 | 0 | 1 1 1 2 127 | 1 | 1 2 3 4 128 | 2 | 1 1 1 1 129 | ``` 130 | 131 | The cost to enter the tile `(1, 3)` (row 1, column 3) from any of its adjacent tiles is 4 units. So the real distance between `(0, 2)` and `(1, 3)` would be 4 units. 132 | 133 | ### Graph Example 134 | In order to execute this example, run the following command: 135 | ```sh 136 | composer example:graph 137 | ``` 138 | 139 | Important notes: 140 | - This example calculates the shortest path between two given nodes in a directed graph. 141 | - A node's position is determined by its X and Y coordinates. 142 | - The `Link` class specifies an arc (unidirectional connection) between two nodes. For instance `Link(A, B, D)` represents an arc from the node `A` to the node `B`, with a distance of `D` units. 143 | 144 | Benchmark 145 | --------- 146 | This project contains a benchmark utility that can be used to test the algorithm's efficiency. This can be particularly useful to evaluate any changes made to the algorithm. The benchmark runs against the Terrain example. 147 | 148 | To execute it with the default parameters, simply run: 149 | ```sh 150 | composer benchmark 151 | ``` 152 | 153 | For a full list of parameters, please run: 154 | ```sh 155 | composer benchmark help benchmark 156 | ``` 157 | 158 | For instance, the following command runs the algorithm against 10 different terrains of size 5x5, another 10 different terrains of size 12x12, and it uses 123456 as its seed to randomly generate the costs of each one of the terrain tiles: 159 | ```sh 160 | composer benchmark -- --iterations=10 --size=5 --size=12 --seed=123456 161 | ``` 162 | 163 | Contributing 164 | ------------ 165 | Contributions to this project are always welcome. If you want to make a contribution, please fork the project, create a feature branch, and send a pull request. 166 | 167 | ### Development environment 168 | In order to set up your development environment, please follow these steps: 169 | 1. Install [Docker](https://www.docker.com/). 170 | 2. Build the image: `docker build -t php-a-star .` 171 | 3. Run the image: 172 | ```sh 173 | docker run -it \ 174 | --mount type=bind,source="$(pwd)",target=/opt/php-a-star \ 175 | php-a-star \ 176 | sh 177 | ``` 178 | 179 | ### Coding Standards 180 | To ensure a consistent code base, please make sure your code follows the following conventions: 181 | - The code should follow the standards defined in the [PSR-12](https://www.php-fig.org/psr/psr-12/) document. 182 | - Use camelCase for naming variables, instead of underscores. 183 | - Use parentheses when instantiating classes regardless of the number of arguments the constructor has. 184 | - Write self-documenting code instead of actual comments (unless strictly necessary). 185 | 186 | In other words, please imitate the existing code. 187 | 188 | Please remember that you can verify that your code adheres to the coding standards by running `composer coding-standards`. 189 | 190 | ### Tests 191 | This project has been developed following the [TDD](https://en.wikipedia.org/wiki/Test-driven_development) principles, and it strives for maximum test coverage. Therefore, you are encouraged to write tests for your new code. If your code is a bug fix, please write a test that proves that your code actually fixes the bug. 192 | 193 | If you don't know how to write tests, please don't be discouraged, and send your pull request without tests, I will try to add them myself later. 194 | 195 | To run the test suite and the code coverage report, simply execute `composer test`. 196 | 197 | ### Static Analysis Tools 198 | To ensure the quality of the codebase is of a high standard, the following static analysis tools are run as part of the CI pipeline: 199 | 200 | | Tool | Notes | How to run | 201 | | ---- | ----- | ---------- | 202 | | [Scrutinizer](https://scrutinizer-ci.com/g/jmgq/php-a-star/) | Tracks how data flows through the application to detect security issues, bugs, unused code, and more | Online only | 203 | | Phan | Runs on the lowest, most strict level | `composer static-analysis:phan` | 204 | | PHPStan | Runs on the highest, most strict level | `composer static-analysis:phpstan` | 205 | | Psalm | Runs on lowest, most strict level | `composer static-analysis:psalm` | 206 | 207 | You can run all the local static analysis tools with `composer static-analysis`. 208 | 209 | ### Contributors 210 | Feel free to add yourself to the list of [contributors](CONTRIBUTORS.md). 211 | 212 | Changelog 213 | --------- 214 | Read the [changelog](CHANGELOG.md). 215 | -------------------------------------------------------------------------------- /benchmark/BenchmarkCommand.php: -------------------------------------------------------------------------------- 1 | setName($name) 34 | ->setDescription($description) 35 | ->setHelp($help) 36 | ->addSizeOption() 37 | ->addIterationsOption() 38 | ->addSeedOption(); 39 | } 40 | 41 | /** 42 | * {@inheritdoc} 43 | */ 44 | protected function execute(InputInterface $input, OutputInterface $output): int 45 | { 46 | $styledOutput = new SymfonyStyle($input, $output); 47 | 48 | $inputValidator = new InputValidator($styledOutput); 49 | $hasValidInput = $inputValidator->validate($input); 50 | 51 | if (!$hasValidInput) { 52 | return self::ERROR_EXIT_CODE; 53 | } 54 | 55 | /** @var int[] $sizes */ 56 | $sizes = $input->getOption(self::SIZE_OPTION); 57 | /** @var int $iterations */ 58 | $iterations = $input->getOption(self::ITERATIONS_OPTION); 59 | /** @var int | null $seed */ 60 | $seed = $input->getOption(self::SEED_OPTION); 61 | 62 | $progressBar = new SymfonyProgressBar($styledOutput->createProgressBar()); 63 | $benchmarkRunner = new BenchmarkRunner($progressBar); 64 | 65 | $results = $benchmarkRunner->run($sizes, $iterations, $seed); 66 | 67 | $this->printResults($results, $styledOutput); 68 | 69 | return self::SUCCESS_EXIT_CODE; 70 | } 71 | 72 | private function addSizeOption(): BenchmarkCommand 73 | { 74 | $description = 'Number of rows and columns of the terrain'; 75 | $defaultValue = ['5', '10', '15', '20', '25']; 76 | 77 | $this->addOption( 78 | self::SIZE_OPTION, 79 | 's', 80 | InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 81 | $description, 82 | $defaultValue 83 | ); 84 | 85 | return $this; 86 | } 87 | 88 | private function addIterationsOption(): BenchmarkCommand 89 | { 90 | $description = 'Number of times the algorithm will run against each terrain'; 91 | $defaultValue = 10; 92 | 93 | $this->addOption(self::ITERATIONS_OPTION, 'i', InputOption::VALUE_REQUIRED, $description, $defaultValue); 94 | 95 | return $this; 96 | } 97 | 98 | private function addSeedOption(): BenchmarkCommand 99 | { 100 | $description = 'Integer used to generate random costs. Set the same value in order to replicate an execution'; 101 | $defaultValue = null; 102 | 103 | $this->addOption(self::SEED_OPTION, 'e', InputOption::VALUE_REQUIRED, $description, $defaultValue); 104 | 105 | return $this; 106 | } 107 | 108 | /** 109 | * @param Result[] $results 110 | * @param StyleInterface $output 111 | */ 112 | private function printResults(array $results, StyleInterface $output): void 113 | { 114 | $output->newLine(); 115 | 116 | $resultAggregator = new ResultAggregator(); 117 | $aggregatedResults = $resultAggregator->process($results); 118 | 119 | $resultPrinter = new ResultPrinter($output); 120 | $resultPrinter->display($aggregatedResults); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /benchmark/BenchmarkRunner.php: -------------------------------------------------------------------------------- 1 | progressBar = $progressBar; 20 | $this->terrainGenerator = new TerrainGenerator(); 21 | $this->stopwatch = new Stopwatch(); 22 | } 23 | 24 | /** 25 | * @param int[] $sizes 26 | * @param int $iterations 27 | * @param int | null $seed 28 | * @return Result[] 29 | */ 30 | public function run(array $sizes, int $iterations, ?int $seed): array 31 | { 32 | $results = []; 33 | 34 | $steps = count($sizes) * $iterations; 35 | $this->progressBar->start($steps); 36 | 37 | foreach ($sizes as $size) { 38 | for ($i = 0; $i < $iterations; $i++) { 39 | $terrain = $this->terrainGenerator->generate($size, $size, $seed); 40 | $domainLogic = new DomainLogic($terrain); 41 | $aStar = new AStar($domainLogic); 42 | 43 | $start = new Position(0, 0); 44 | $goal = new Position($size - 1, $size - 1); 45 | 46 | $this->stopwatch->start('benchmark'); 47 | 48 | $solution = $aStar->run($start, $goal); 49 | 50 | $event = $this->stopwatch->stop('benchmark'); 51 | 52 | $solutionFound = !empty($solution); 53 | 54 | $results[] = new Result($size, (int) $event->getDuration(), $solutionFound); 55 | 56 | $this->stopwatch->reset(); 57 | 58 | $this->progressBar->advance(); 59 | } 60 | } 61 | 62 | $this->progressBar->finish(); 63 | 64 | return $results; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /benchmark/InputValidator.php: -------------------------------------------------------------------------------- 1 | getOption(BenchmarkCommand::SIZE_OPTION); 19 | $iterations = $input->getOption(BenchmarkCommand::ITERATIONS_OPTION); 20 | $seed = $input->getOption(BenchmarkCommand::SEED_OPTION); 21 | 22 | foreach ($sizes as $size) { 23 | if (!$this->isPositiveInteger($size)) { 24 | $this->output->error('The size must be an integer greater than 0'); 25 | $hasValidInput = false; 26 | } 27 | } 28 | 29 | if (!$this->isPositiveInteger($iterations)) { 30 | $this->output->error('The number of iterations must be an integer greater than 0'); 31 | $hasValidInput = false; 32 | } 33 | 34 | if (!$this->isOptionalInteger($seed)) { 35 | $this->output->error('The seed must be an integer'); 36 | $hasValidInput = false; 37 | } 38 | 39 | return $hasValidInput; 40 | } 41 | 42 | private function isPositiveInteger(mixed $value): bool 43 | { 44 | $positiveInteger = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); 45 | 46 | return $positiveInteger !== false; 47 | } 48 | 49 | private function isOptionalInteger(mixed $value): bool 50 | { 51 | $integer = filter_var($value, FILTER_VALIDATE_INT); 52 | 53 | return $integer !== false || $value === null; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /benchmark/ProgressBarInterface.php: -------------------------------------------------------------------------------- 1 | size = $this->filterNaturalNumber($size, 'size'); 23 | $this->averageDuration = $this->filterNonNegativeInteger($averageDuration, 'average duration'); 24 | $this->minimumDuration = $this->filterNonNegativeInteger($minimumDuration, 'minimum duration'); 25 | $this->maximumDuration = $this->filterNonNegativeInteger($maximumDuration, 'maximum duration'); 26 | $this->numberOfSolutions = $this->filterNonNegativeInteger($numberOfSolutions, 'number of solutions'); 27 | $this->numberOfTerrains = $this->filterNaturalNumber($numberOfTerrains, 'number of terrains'); 28 | } 29 | 30 | public function getSize(): int 31 | { 32 | return $this->size; 33 | } 34 | 35 | public function getAverageDuration(): int 36 | { 37 | return $this->averageDuration; 38 | } 39 | 40 | public function getMinimumDuration(): int 41 | { 42 | return $this->minimumDuration; 43 | } 44 | 45 | public function getMaximumDuration(): int 46 | { 47 | return $this->maximumDuration; 48 | } 49 | 50 | public function getNumberOfSolutions(): int 51 | { 52 | return $this->numberOfSolutions; 53 | } 54 | 55 | public function getNumberOfTerrains(): int 56 | { 57 | return $this->numberOfTerrains; 58 | } 59 | 60 | private function filterNaturalNumber(int $value, string $parameterName): int 61 | { 62 | if ($value < 1) { 63 | throw new \InvalidArgumentException("Invalid $parameterName: $value"); 64 | } 65 | 66 | return $value; 67 | } 68 | 69 | private function filterNonNegativeInteger(int $value, string $parameterName): int 70 | { 71 | if ($value < 0) { 72 | throw new \InvalidArgumentException("Invalid $parameterName: $value"); 73 | } 74 | 75 | return $value; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /benchmark/Result/Result.php: -------------------------------------------------------------------------------- 1 | size = $this->filterSize($size); 14 | $this->duration = $this->filterDuration($duration); 15 | $this->hasSolution = $hasSolution; 16 | } 17 | 18 | public function getSize(): int 19 | { 20 | return $this->size; 21 | } 22 | 23 | public function getDuration(): int 24 | { 25 | return $this->duration; 26 | } 27 | 28 | public function hasSolution(): bool 29 | { 30 | return $this->hasSolution; 31 | } 32 | 33 | private function filterSize(int $size): int 34 | { 35 | if ($size < 1) { 36 | throw new \InvalidArgumentException("Invalid size: $size"); 37 | } 38 | 39 | return $size; 40 | } 41 | 42 | private function filterDuration(int $duration): int 43 | { 44 | if ($duration < 0) { 45 | throw new \InvalidArgumentException("Invalid duration: $duration"); 46 | } 47 | 48 | return $duration; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /benchmark/Result/ResultAggregator.php: -------------------------------------------------------------------------------- 1 | groupBySize($results); 16 | 17 | foreach ($sizeToResultsMap as $size => $groupedResults) { 18 | /** @var non-empty-array $durations */ 19 | $durations = $this->getDurations($groupedResults); 20 | $averageDuration = $this->averageDuration($durations); 21 | $minimumDuration = min($durations); 22 | $maximumDuration = max($durations); 23 | $numberOfSolutions = $this->getNumberOfResultsWithASolution($groupedResults); 24 | $numberOfTerrains = count($groupedResults); 25 | 26 | $aggregatedResults[] = new AggregatedResult( 27 | $size, 28 | $averageDuration, 29 | $minimumDuration, 30 | $maximumDuration, 31 | $numberOfSolutions, 32 | $numberOfTerrains 33 | ); 34 | } 35 | 36 | return $aggregatedResults; 37 | } 38 | 39 | /** 40 | * @param Result[] $results 41 | * @return array 42 | */ 43 | private function groupBySize(array $results): array 44 | { 45 | $groupedResults = []; 46 | 47 | foreach ($results as $result) { 48 | $groupedResults[$result->getSize()][] = $result; 49 | } 50 | 51 | return $groupedResults; 52 | } 53 | 54 | /** 55 | * @param Result[] $results 56 | * @return int[] 57 | */ 58 | private function getDurations(array $results): array 59 | { 60 | return array_map(static fn ($result) => $result->getDuration(), $results); 61 | } 62 | 63 | /** 64 | * @param int[] $durations 65 | * @return int 66 | */ 67 | private function averageDuration(array $durations): int 68 | { 69 | return (int) (array_sum($durations) / count($durations)); 70 | } 71 | 72 | /** 73 | * @param Result[] $results 74 | * @return int 75 | */ 76 | private function getNumberOfResultsWithASolution(array $results): int 77 | { 78 | $solvedResults = array_filter($results, static fn ($result) => $result->hasSolution()); 79 | 80 | return count($solvedResults); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /benchmark/Result/ResultPrinter.php: -------------------------------------------------------------------------------- 1 | orderResults($results); 21 | 22 | foreach ($orderedResults as $result) { 23 | $size = $result->getSize() . 'x' . $result->getSize(); 24 | $averageDuration = $result->getAverageDuration() . 'ms'; 25 | $minimumDuration = $result->getMinimumDuration() . 'ms'; 26 | $maximumDuration = $result->getMaximumDuration() . 'ms'; 27 | $solutionFound = $this->formatSolutionFound($result); 28 | 29 | $tableRows[] = [$size, $averageDuration, $minimumDuration, $maximumDuration, $solutionFound]; 30 | } 31 | 32 | $tableHeaders = ['Size', 'Avg Duration', 'Min Duration', 'Max Duration', 'Solved?']; 33 | 34 | $this->output->table($tableHeaders, $tableRows); 35 | } 36 | 37 | /** 38 | * @param AggregatedResult[] $results 39 | * @return AggregatedResult[] 40 | */ 41 | private function orderResults(array $results): array 42 | { 43 | usort($results, static fn (AggregatedResult $a, AggregatedResult $b) => $a->getSize() - $b->getSize()); 44 | 45 | return $results; 46 | } 47 | 48 | private function formatSolutionFound(AggregatedResult $result): string 49 | { 50 | $allResultsAreSolved = $result->getNumberOfSolutions() === $result->getNumberOfTerrains(); 51 | if ($allResultsAreSolved) { 52 | return 'Yes'; 53 | } 54 | 55 | $allResultsAreUnsolved = $result->getNumberOfSolutions() === 0; 56 | if ($allResultsAreUnsolved) { 57 | return 'No'; 58 | } 59 | 60 | return 'Sometimes'; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /benchmark/SymfonyProgressBar.php: -------------------------------------------------------------------------------- 1 | progressBar->start($numberOfSteps); 16 | } 17 | 18 | public function advance(): void 19 | { 20 | $this->progressBar->advance(); 21 | } 22 | 23 | public function finish(): void 24 | { 25 | $this->progressBar->finish(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /benchmark/TerrainGenerator.php: -------------------------------------------------------------------------------- 1 | validatePositiveInteger($rows); 12 | $this->validatePositiveInteger($columns); 13 | 14 | $seed !== null ? mt_srand($seed) : mt_srand(); 15 | 16 | $terrainCost = []; 17 | 18 | foreach (range(0, $rows - 1) as $row) { 19 | foreach (range(0, $columns - 1) as $column) { 20 | $terrainCost[$row][$column] = mt_rand(1, 10); 21 | } 22 | } 23 | 24 | return new TerrainCost($terrainCost); 25 | } 26 | 27 | private function validatePositiveInteger(int $number): void 28 | { 29 | if ($number < 1) { 30 | throw new \InvalidArgumentException("Invalid positive integer: $number"); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /benchmark/benchmark.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | add($benchmarkCommand); 18 | $application->setDefaultCommand((string) $benchmarkCommand->getName()); 19 | 20 | $application->run(); 21 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jmgq/a-star", 3 | "description": "A* (A Star) algorithm for PHP", 4 | "keywords": ["a", "a star", "search", "pathfinding", "algorithm"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Jose Gonzalez", 9 | "email": "jmgq@jmgq.es", 10 | "homepage": "https://www.jmgq.es" 11 | } 12 | ], 13 | "autoload": { 14 | "psr-4": { 15 | "JMGQ\\AStar\\": "src/" 16 | } 17 | }, 18 | "autoload-dev": { 19 | "psr-4": { 20 | "JMGQ\\AStar\\Benchmark\\": "benchmark/", 21 | "JMGQ\\AStar\\Example\\": "examples/", 22 | "JMGQ\\AStar\\Tests\\": "tests/" 23 | } 24 | }, 25 | "require": { 26 | "php": ">=8.0" 27 | }, 28 | "require-dev": { 29 | "phan/phan": "^5.0", 30 | "php-coveralls/php-coveralls": "^2.0", 31 | "phpstan/phpstan": "^1.0", 32 | "phpunit/phpunit": "^9.0", 33 | "psalm/plugin-phpunit": "^0.15.1", 34 | "squizlabs/php_codesniffer": "^3.0", 35 | "symfony/console": "^5.0", 36 | "vimeo/psalm": "^4.5" 37 | }, 38 | "scripts": { 39 | "benchmark": "@php benchmark/benchmark.php", 40 | "coding-standards": "phpcs --standard=PSR12 --ignore=vendor .", 41 | "example:graph": "@php examples/Graph/example.php", 42 | "example:terrain": "@php examples/Terrain/example.php", 43 | "static-analysis": [ 44 | "@static-analysis:phan", 45 | "@static-analysis:phpstan", 46 | "@static-analysis:psalm" 47 | ], 48 | "static-analysis:phan": "phan --config-file=.phan.config.php --allow-polyfill-parser --analyze-twice", 49 | "static-analysis:phpstan": "phpstan analyse --level=max src tests examples benchmark", 50 | "static-analysis:psalm": "psalm", 51 | "test": "XDEBUG_MODE=coverage phpunit" 52 | }, 53 | "scripts-descriptions": { 54 | "benchmark": "Runs the benchmark.", 55 | "coding-standards": "Checks the code adheres to the coding standards.", 56 | "example:graph": "Runs the Graph example.", 57 | "example:terrain": "Runs the Terrain example.", 58 | "static-analysis": "Runs all the static analysis tools.", 59 | "static-analysis:phan": "Runs the Phan static analysis tool.", 60 | "static-analysis:phpstan": "Runs the PHPStan static analysis tool.", 61 | "static-analysis:psalm": "Runs the Psalm static analysis tool.", 62 | "test": "Runs unit tests and generates a coverage report." 63 | }, 64 | "config": { 65 | "sort-packages": true 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /examples/Graph/Coordinate.php: -------------------------------------------------------------------------------- 1 | x; 14 | } 15 | 16 | public function getY(): int 17 | { 18 | return $this->y; 19 | } 20 | 21 | public function getId(): string 22 | { 23 | return $this->x . 'x' . $this->y; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/Graph/DomainLogic.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class DomainLogic implements DomainLogicInterface 11 | { 12 | public function __construct(private Graph $graph) 13 | { 14 | } 15 | 16 | /** 17 | * @param Coordinate $node 18 | * @return Coordinate[] 19 | */ 20 | public function getAdjacentNodes(mixed $node): iterable 21 | { 22 | return $this->graph->getDirectSuccessors($node); 23 | } 24 | 25 | /** 26 | * @param Coordinate $node 27 | * @param Coordinate $adjacent 28 | * @return float|int 29 | */ 30 | public function calculateRealCost(mixed $node, mixed $adjacent): float | int 31 | { 32 | if (!$this->graph->hasLink($node, $adjacent)) { 33 | throw new \DomainException('The provided nodes are not linked'); 34 | } 35 | 36 | /** 37 | * @phpstan-ignore-next-line 38 | * @psalm-suppress PossiblyNullReference 39 | * getLink cannot be null, as we just checked that the link exists 40 | */ 41 | return $this->graph->getLink($node, $adjacent)->getDistance(); 42 | } 43 | 44 | /** 45 | * @param Coordinate $fromNode 46 | * @param Coordinate $toNode 47 | * @return float|int 48 | */ 49 | public function calculateEstimatedCost(mixed $fromNode, mixed $toNode): float | int 50 | { 51 | return $this->euclideanDistance($fromNode, $toNode); 52 | } 53 | 54 | private function euclideanDistance(Coordinate $a, Coordinate $b): float 55 | { 56 | $xFactor = ($a->getX() - $b->getX()) ** 2; 57 | $yFactor = ($a->getY() - $b->getY()) ** 2; 58 | 59 | return sqrt($xFactor + $yFactor); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /examples/Graph/Graph.php: -------------------------------------------------------------------------------- 1 | addLink($link); 17 | } 18 | } 19 | 20 | public function addLink(Link $link): void 21 | { 22 | $linkId = $this->getLinkId($link->getSource(), $link->getDestination()); 23 | 24 | $this->links[$linkId] = $link; 25 | } 26 | 27 | public function getLink(Coordinate $source, Coordinate $destination): ?Link 28 | { 29 | if ($this->hasLink($source, $destination)) { 30 | $linkId = $this->getLinkId($source, $destination); 31 | 32 | return $this->links[$linkId]; 33 | } 34 | 35 | return null; 36 | } 37 | 38 | public function hasLink(Coordinate $source, Coordinate $destination): bool 39 | { 40 | $linkId = $this->getLinkId($source, $destination); 41 | 42 | return isset($this->links[$linkId]); 43 | } 44 | 45 | /** 46 | * @param Coordinate $node 47 | * @return Coordinate[] 48 | */ 49 | public function getDirectSuccessors(Coordinate $node): array 50 | { 51 | $successors = []; 52 | 53 | foreach ($this->links as $link) { 54 | if ($node->getId() === $link->getSource()->getId()) { 55 | $successors[] = $link->getDestination(); 56 | } 57 | } 58 | 59 | return $successors; 60 | } 61 | 62 | private function getLinkId(Coordinate $source, Coordinate $destination): string 63 | { 64 | return $source->getId() . '|' . $destination->getId(); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /examples/Graph/Link.php: -------------------------------------------------------------------------------- 1 | source = $source; 18 | $this->destination = $destination; 19 | $this->distance = $distance; 20 | } 21 | 22 | public function getSource(): Coordinate 23 | { 24 | return $this->source; 25 | } 26 | 27 | public function getDestination(): Coordinate 28 | { 29 | return $this->destination; 30 | } 31 | 32 | public function getDistance(): float 33 | { 34 | return $this->distance; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/Graph/SequencePrinter.php: -------------------------------------------------------------------------------- 1 | $sequence 10 | */ 11 | public function __construct(private Graph $graph, private iterable $sequence) 12 | { 13 | } 14 | 15 | public function printSequence(): void 16 | { 17 | $coordinatesAsString = []; 18 | 19 | foreach ($this->sequence as $coordinate) { 20 | $coordinatesAsString[] = $this->getCoordinateAsString($coordinate); 21 | } 22 | 23 | $cost = $this->getTotalDistance(); 24 | 25 | if (!empty($coordinatesAsString)) { 26 | echo implode(' => ', $coordinatesAsString); 27 | echo "\n"; 28 | } 29 | 30 | echo "Total cost: $cost"; 31 | } 32 | 33 | private function getCoordinateAsString(Coordinate $coordinate): string 34 | { 35 | return "({$coordinate->getX()}, {$coordinate->getY()})"; 36 | } 37 | 38 | private function getTotalDistance(): float | int 39 | { 40 | /** @var Coordinate[] $sequence */ 41 | $sequence = (array) $this->sequence; 42 | 43 | if (count($sequence) < 2) { 44 | return 0; 45 | } 46 | 47 | $totalDistance = 0; 48 | 49 | $previousNode = array_shift($sequence); 50 | foreach ($sequence as $node) { 51 | $link = $this->graph->getLink($previousNode, $node); 52 | 53 | if (!$link) { 54 | throw new \RuntimeException('Some of the nodes in the provided sequence are not connected'); 55 | } 56 | 57 | $totalDistance += $link->getDistance(); 58 | 59 | $previousNode = $node; 60 | } 61 | 62 | return $totalDistance; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /examples/Graph/example.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | run($start, $goal); 27 | 28 | $printer = new SequencePrinter($graph, $solution); 29 | 30 | $printer->printSequence(); 31 | 32 | echo "\n"; 33 | -------------------------------------------------------------------------------- /examples/Terrain/DomainLogic.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class DomainLogic implements DomainLogicInterface 11 | { 12 | private TerrainCost $terrainCost; 13 | /** @var Position[][] */ 14 | private array $positions; 15 | 16 | public function __construct(TerrainCost $terrainCost) 17 | { 18 | $this->terrainCost = $terrainCost; 19 | 20 | // We store all the Position objects in a matrix for efficiency, so that we don't need to create new Position 21 | // instances every time we call "getAdjacentNodes". If we created new positions with every call, the algorithm 22 | // would still work correctly but it would be a bit slower. 23 | $this->positions = $this->generatePositions($terrainCost); 24 | } 25 | 26 | /** 27 | * @param Position $node 28 | * @return Position[] 29 | */ 30 | public function getAdjacentNodes(mixed $node): iterable 31 | { 32 | $adjacentNodes = []; 33 | 34 | [$startingRow, $endingRow, $startingColumn, $endingColumn] = $this->calculateAdjacentBoundaries($node); 35 | 36 | for ($row = $startingRow; $row <= $endingRow; $row++) { 37 | for ($column = $startingColumn; $column <= $endingColumn; $column++) { 38 | $adjacentNode = $this->positions[$row][$column]; 39 | 40 | if (!$node->isEqualTo($adjacentNode)) { 41 | $adjacentNodes[] = $adjacentNode; 42 | } 43 | } 44 | } 45 | 46 | return $adjacentNodes; 47 | } 48 | 49 | /** 50 | * @param Position $node 51 | * @param Position $adjacent 52 | * @return float|int 53 | */ 54 | public function calculateRealCost(mixed $node, mixed $adjacent): float | int 55 | { 56 | if ($node->isAdjacentTo($adjacent)) { 57 | return $this->terrainCost->getCost($adjacent->getRow(), $adjacent->getColumn()); 58 | } 59 | 60 | return TerrainCost::INFINITE; 61 | } 62 | 63 | /** 64 | * @param Position $fromNode 65 | * @param Position $toNode 66 | * @return float|int 67 | */ 68 | public function calculateEstimatedCost(mixed $fromNode, mixed $toNode): float | int 69 | { 70 | return $this->euclideanDistance($fromNode, $toNode); 71 | } 72 | 73 | private function euclideanDistance(Position $a, Position $b): float 74 | { 75 | $rowFactor = ($a->getRow() - $b->getRow()) ** 2; 76 | $columnFactor = ($a->getColumn() - $b->getColumn()) ** 2; 77 | 78 | return sqrt($rowFactor + $columnFactor); 79 | } 80 | 81 | /** 82 | * @param Position $position 83 | * @return int[] 84 | */ 85 | private function calculateAdjacentBoundaries(Position $position): array 86 | { 87 | if ($position->getRow() === 0) { 88 | $startingRow = 0; 89 | } else { 90 | $startingRow = $position->getRow() - 1; 91 | } 92 | 93 | if ($position->getRow() === $this->terrainCost->getTotalRows() - 1) { 94 | $endingRow = $position->getRow(); 95 | } else { 96 | $endingRow = $position->getRow() + 1; 97 | } 98 | 99 | if ($position->getColumn() === 0) { 100 | $startingColumn = 0; 101 | } else { 102 | $startingColumn = $position->getColumn() - 1; 103 | } 104 | 105 | if ($position->getColumn() === $this->terrainCost->getTotalColumns() - 1) { 106 | $endingColumn = $position->getColumn(); 107 | } else { 108 | $endingColumn = $position->getColumn() + 1; 109 | } 110 | 111 | return [$startingRow, $endingRow, $startingColumn, $endingColumn]; 112 | } 113 | 114 | /** 115 | * @param TerrainCost $terrainCost 116 | * @return Position[][] 117 | */ 118 | private function generatePositions(TerrainCost $terrainCost): array 119 | { 120 | $positions = []; 121 | 122 | for ($row = 0; $row < $terrainCost->getTotalRows(); $row++) { 123 | for ($column = 0; $column < $terrainCost->getTotalColumns(); $column++) { 124 | $positions[$row][$column] = new Position($row, $column); 125 | } 126 | } 127 | 128 | return $positions; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /examples/Terrain/Position.php: -------------------------------------------------------------------------------- 1 | validateNonNegativeInteger($row); 15 | $this->validateNonNegativeInteger($column); 16 | 17 | $this->row = $row; 18 | $this->column = $column; 19 | } 20 | 21 | public function getRow(): int 22 | { 23 | return $this->row; 24 | } 25 | 26 | public function getColumn(): int 27 | { 28 | return $this->column; 29 | } 30 | 31 | public function isEqualTo(Position $other): bool 32 | { 33 | return $this->getRow() === $other->getRow() && $this->getColumn() === $other->getColumn(); 34 | } 35 | 36 | public function isAdjacentTo(Position $other): bool 37 | { 38 | return abs($this->getRow() - $other->getRow()) <= 1 && abs($this->getColumn() - $other->getColumn()) <= 1; 39 | } 40 | 41 | public function getUniqueNodeId(): string 42 | { 43 | return $this->row . 'x' . $this->column; 44 | } 45 | 46 | private function validateNonNegativeInteger(int $integer): void 47 | { 48 | if ($integer < 0) { 49 | throw new \InvalidArgumentException("Invalid non negative integer: $integer"); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /examples/Terrain/SequencePrinter.php: -------------------------------------------------------------------------------- 1 | */ 9 | private iterable $sequence; 10 | private string $emptyTileToken = '-'; 11 | private int $tileSize = 3; 12 | private string $padToken = ' '; 13 | 14 | /** 15 | * @param TerrainCost $terrainCost 16 | * @param iterable $sequence 17 | */ 18 | public function __construct(TerrainCost $terrainCost, iterable $sequence) 19 | { 20 | $this->terrainCost = $terrainCost; 21 | $this->sequence = $sequence; 22 | } 23 | 24 | public function getEmptyTileToken(): string 25 | { 26 | return $this->emptyTileToken; 27 | } 28 | 29 | public function setEmptyTileToken(string $emptyTileToken): void 30 | { 31 | $this->emptyTileToken = $emptyTileToken; 32 | } 33 | 34 | public function getTileSize(): int 35 | { 36 | return $this->tileSize; 37 | } 38 | 39 | public function setTileSize(int $tileSize): void 40 | { 41 | if ($tileSize < 1) { 42 | throw new \InvalidArgumentException("Invalid tile size: $tileSize"); 43 | } 44 | 45 | $this->tileSize = $tileSize; 46 | } 47 | 48 | public function getPadToken(): string 49 | { 50 | return $this->padToken; 51 | } 52 | 53 | public function setPadToken(string $padToken): void 54 | { 55 | $this->padToken = $padToken; 56 | } 57 | 58 | public function printSequence(): void 59 | { 60 | $board = $this->generateEmptyBoard(); 61 | 62 | $step = 1; 63 | foreach ($this->sequence as $position) { 64 | $board[$position->getRow()][$position->getColumn()] = $this->getTile((string) $step); 65 | 66 | $step++; 67 | } 68 | 69 | $stringBoard = []; 70 | 71 | foreach ($board as $row) { 72 | $stringBoard[] = implode('', $row); 73 | } 74 | 75 | echo implode("\n", $stringBoard); 76 | } 77 | 78 | /** 79 | * @return string[][] 80 | */ 81 | private function generateEmptyBoard(): array 82 | { 83 | $emptyTile = $this->getTile($this->getEmptyTileToken()); 84 | 85 | $emptyRow = array_fill(0, $this->terrainCost->getTotalColumns(), $emptyTile); 86 | 87 | $board = array_fill(0, $this->terrainCost->getTotalRows(), $emptyRow); 88 | 89 | return $board; 90 | } 91 | 92 | private function getTile(string $value): string 93 | { 94 | return str_pad($value, $this->getTileSize(), $this->getPadToken(), STR_PAD_LEFT); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /examples/Terrain/StaticExample.php: -------------------------------------------------------------------------------- 1 | run($start, $goal); 15 | 16 | $printer = new SequencePrinter($terrainCost, $solution); 17 | 18 | $printer->printSequence(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/Terrain/TerrainCost.php: -------------------------------------------------------------------------------- 1 | terrainCost = self::validateTerrainCosts($terrainCost); 28 | } 29 | 30 | public function getCost(int $row, int $column): int 31 | { 32 | if (!isset($this->terrainCost[$row][$column])) { 33 | throw new \InvalidArgumentException("Invalid tile: $row, $column"); 34 | } 35 | 36 | return $this->terrainCost[$row][$column]; 37 | } 38 | 39 | public function getTotalRows(): int 40 | { 41 | return count($this->terrainCost); 42 | } 43 | 44 | public function getTotalColumns(): int 45 | { 46 | return count($this->terrainCost[0]); 47 | } 48 | 49 | /** 50 | * @param int[][] $terrainCost 51 | * @return bool 52 | */ 53 | private static function isEmpty(array $terrainCost): bool 54 | { 55 | if (!empty($terrainCost)) { 56 | $firstRow = reset($terrainCost); 57 | 58 | return empty($firstRow); 59 | } 60 | 61 | return true; 62 | } 63 | 64 | /** 65 | * @param mixed[][] $terrain 66 | * @return int[][] 67 | */ 68 | private static function validateTerrainCosts(array $terrain): array 69 | { 70 | $validTerrain = []; 71 | 72 | foreach ($terrain as $row => $rowValues) { 73 | /** @psalm-suppress MixedAssignment PSalm is unable to determine that $value is of mixed type */ 74 | foreach ($rowValues as $column => $value) { 75 | $integerValue = filter_var($value, FILTER_VALIDATE_INT); 76 | 77 | if ($integerValue === false) { 78 | throw new \InvalidArgumentException('Invalid terrain cost: ' . print_r($value, true)); 79 | } 80 | 81 | $validTerrain[$row][$column] = $integerValue; 82 | } 83 | } 84 | 85 | return $validTerrain; 86 | } 87 | 88 | /** 89 | * @param int[][] $associativeArray 90 | * @return int[][] 91 | */ 92 | private static function convertToNumericArray(array $associativeArray): array 93 | { 94 | $numericArray = []; 95 | 96 | foreach ($associativeArray as $row) { 97 | $numericArray[] = array_values($row); 98 | } 99 | 100 | return $numericArray; 101 | } 102 | 103 | /** 104 | * @param int[][] $terrain 105 | * @return bool 106 | */ 107 | private static function isRectangular(array $terrain): bool 108 | { 109 | // @phpstan-ignore-next-line reset won't return false as we have already checked that the terrain is not empty 110 | $numberOfColumnsInFirstRow = count(reset($terrain)); 111 | 112 | foreach ($terrain as $row) { 113 | if (count($row) !== $numberOfColumnsInFirstRow) { 114 | return false; 115 | } 116 | } 117 | 118 | return true; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /examples/Terrain/example.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | run($start, $goal); 24 | 25 | $printer = new SequencePrinter($terrainCost, $solution); 26 | 27 | $printer->printSequence(); 28 | 29 | echo "\n"; 30 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - message: '#Parameter .+ given#' 4 | path: tests/* 5 | - message: '#Cannot cast mixed to .+#' 6 | path: tests/* 7 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | tests 11 | 12 | 13 | 14 | 15 | 16 | src 17 | benchmark 18 | examples 19 | 20 | 21 | 22 | benchmark/benchmark.php 23 | examples/Graph/example.php 24 | examples/Terrain/example.php 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/AStar.php: -------------------------------------------------------------------------------- 1 | */ 15 | private DomainLogicInterface $domainLogic; 16 | /** @var NodeCollectionInterface | NodeHashTable */ 17 | private NodeCollectionInterface $openList; 18 | /** @var NodeCollectionInterface | NodeHashTable */ 19 | private NodeCollectionInterface $closedList; 20 | 21 | /** 22 | * @param DomainLogicInterface $domainLogic 23 | */ 24 | public function __construct(DomainLogicInterface $domainLogic) 25 | { 26 | $this->domainLogic = $domainLogic; 27 | $this->openList = new NodeHashTable(); 28 | $this->closedList = new NodeHashTable(); 29 | } 30 | 31 | /** 32 | * @param TState $start 33 | * @param TState $goal 34 | * @return iterable 35 | */ 36 | public function run(mixed $start, mixed $goal): iterable 37 | { 38 | $startNode = new Node($start); 39 | $goalNode = new Node($goal); 40 | 41 | return $this->executeAlgorithm($startNode, $goalNode); 42 | } 43 | 44 | /** 45 | * @param Node $start 46 | * @param Node $goal 47 | * @return iterable 48 | */ 49 | private function executeAlgorithm(Node $start, Node $goal): iterable 50 | { 51 | $path = []; 52 | 53 | $this->clear(); 54 | 55 | $start->setG(0); 56 | $start->setH($this->calculateEstimatedCost($start, $goal)); 57 | 58 | $this->openList->add($start); 59 | 60 | while (!$this->openList->isEmpty()) { 61 | /** @var Node $currentNode Cannot be null because the open list is not empty */ 62 | $currentNode = $this->openList->extractBest(); 63 | 64 | $this->closedList->add($currentNode); 65 | 66 | if ($currentNode->getId() === $goal->getId()) { 67 | $path = $this->generatePathFromStartNodeTo($currentNode); 68 | break; 69 | } 70 | 71 | $successors = $this->getAdjacentNodesWithTentativeScore($currentNode, $goal); 72 | 73 | $this->evaluateSuccessors($successors, $currentNode); 74 | } 75 | 76 | return $path; 77 | } 78 | 79 | /** 80 | * Sets the algorithm to its initial state 81 | */ 82 | private function clear(): void 83 | { 84 | $this->openList->clear(); 85 | $this->closedList->clear(); 86 | } 87 | 88 | /** 89 | * @param Node $node 90 | * @return iterable> 91 | */ 92 | private function generateAdjacentNodes(Node $node): iterable 93 | { 94 | $adjacentNodes = []; 95 | 96 | $adjacentStates = $this->domainLogic->getAdjacentNodes($node->getState()); 97 | 98 | foreach ($adjacentStates as $state) { 99 | $adjacentNodes[] = new Node($state); 100 | } 101 | 102 | return $adjacentNodes; 103 | } 104 | 105 | /** 106 | * @param Node $node 107 | * @param Node $adjacent 108 | * @return float | int 109 | */ 110 | private function calculateRealCost(Node $node, Node $adjacent): float | int 111 | { 112 | $state = $node->getState(); 113 | $adjacentState = $adjacent->getState(); 114 | 115 | return $this->domainLogic->calculateRealCost($state, $adjacentState); 116 | } 117 | 118 | /** 119 | * @param Node $start 120 | * @param Node $end 121 | * @return float | int 122 | */ 123 | private function calculateEstimatedCost(Node $start, Node $end): float | int 124 | { 125 | $startState = $start->getState(); 126 | $endState = $end->getState(); 127 | 128 | return $this->domainLogic->calculateEstimatedCost($startState, $endState); 129 | } 130 | 131 | /** 132 | * @param Node $node 133 | * @return iterable 134 | */ 135 | private function generatePathFromStartNodeTo(Node $node): iterable 136 | { 137 | $path = []; 138 | 139 | $currentNode = $node; 140 | 141 | while ($currentNode !== null) { 142 | array_unshift($path, $currentNode->getState()); 143 | 144 | $currentNode = $currentNode->getParent(); 145 | } 146 | 147 | return $path; 148 | } 149 | 150 | /** 151 | * @param Node $node 152 | * @param Node $goal 153 | * @return iterable> 154 | */ 155 | private function getAdjacentNodesWithTentativeScore(Node $node, Node $goal): iterable 156 | { 157 | $nodes = $this->generateAdjacentNodes($node); 158 | 159 | foreach ($nodes as $adjacentNode) { 160 | $adjacentNode->setG($node->getG() + $this->calculateRealCost($node, $adjacentNode)); 161 | $adjacentNode->setH($this->calculateEstimatedCost($adjacentNode, $goal)); 162 | } 163 | 164 | return $nodes; 165 | } 166 | 167 | /** 168 | * @param iterable> $successors 169 | * @param Node $parent 170 | */ 171 | private function evaluateSuccessors(iterable $successors, Node $parent): void 172 | { 173 | foreach ($successors as $successor) { 174 | if ($this->nodeAlreadyPresentInListWithBetterOrSameRealCost($successor, $this->openList)) { 175 | continue; 176 | } 177 | 178 | if ($this->nodeAlreadyPresentInListWithBetterOrSameRealCost($successor, $this->closedList)) { 179 | continue; 180 | } 181 | 182 | $successor->setParent($parent); 183 | 184 | $this->closedList->remove($successor); 185 | 186 | $this->openList->add($successor); 187 | } 188 | } 189 | 190 | /** 191 | * @param Node $node 192 | * @param NodeCollectionInterface $nodeList 193 | * @return bool 194 | */ 195 | private function nodeAlreadyPresentInListWithBetterOrSameRealCost( 196 | Node $node, 197 | NodeCollectionInterface $nodeList 198 | ): bool { 199 | if ($nodeList->contains($node)) { 200 | /** @var Node $nodeInList Cannot be null because the list contains it */ 201 | $nodeInList = $nodeList->get($node->getId()); 202 | 203 | if ($node->getG() >= $nodeInList->getG()) { 204 | return true; 205 | } 206 | } 207 | 208 | return false; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/DomainLogicInterface.php: -------------------------------------------------------------------------------- 1 | > 10 | * @internal 11 | */ 12 | interface NodeCollectionInterface extends \Traversable 13 | { 14 | /** 15 | * Obtains the node with the lowest F score. It also removes it from the collection. 16 | * 17 | * @return Node | null 18 | */ 19 | public function extractBest(): ?Node; 20 | 21 | /** 22 | * @param string $nodeId 23 | * @return Node | null 24 | */ 25 | public function get(string $nodeId): ?Node; 26 | 27 | /** 28 | * @param Node $node 29 | */ 30 | public function add(Node $node): void; 31 | 32 | /** 33 | * @param Node $node 34 | */ 35 | public function remove(Node $node): void; 36 | 37 | public function isEmpty(): bool; 38 | 39 | /** 40 | * @param Node $node 41 | * @return bool 42 | */ 43 | public function contains(Node $node): bool; 44 | 45 | /** 46 | * Empties the collection 47 | */ 48 | public function clear(): void; 49 | } 50 | -------------------------------------------------------------------------------- /src/Node/Collection/NodeHashTable.php: -------------------------------------------------------------------------------- 1 | > 10 | * @implements NodeCollectionInterface 11 | * @internal 12 | */ 13 | class NodeHashTable implements \IteratorAggregate, NodeCollectionInterface 14 | { 15 | /** @var Node[] */ 16 | private array $nodes = []; 17 | 18 | /** 19 | * {@inheritdoc} 20 | * @return \ArrayIterator> 21 | */ 22 | public function getIterator(): \Traversable 23 | { 24 | return new \ArrayIterator($this->nodes); 25 | } 26 | 27 | /** 28 | * {@inheritdoc} 29 | */ 30 | public function extractBest(): ?Node 31 | { 32 | $bestNode = null; 33 | 34 | foreach ($this->nodes as $node) { 35 | if ($bestNode === null || $node->getF() < $bestNode->getF()) { 36 | $bestNode = $node; 37 | } 38 | } 39 | 40 | if ($bestNode !== null) { 41 | $this->remove($bestNode); 42 | } 43 | 44 | return $bestNode; 45 | } 46 | 47 | public function get(string $nodeId): ?Node 48 | { 49 | return $this->nodes[$nodeId] ?? null; 50 | } 51 | 52 | public function add(Node $node): void 53 | { 54 | $this->nodes[$node->getId()] = $node; 55 | } 56 | 57 | public function remove(Node $node): void 58 | { 59 | unset($this->nodes[$node->getId()]); 60 | } 61 | 62 | public function isEmpty(): bool 63 | { 64 | return empty($this->nodes); 65 | } 66 | 67 | public function contains(Node $node): bool 68 | { 69 | return isset($this->nodes[$node->getId()]); 70 | } 71 | 72 | /** 73 | * {@inheritdoc} 74 | */ 75 | public function clear(): void 76 | { 77 | $this->nodes = []; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Node/Node.php: -------------------------------------------------------------------------------- 1 | | null */ 15 | private ?Node $parent = null; 16 | /** @psalm-suppress PropertyNotSetInConstructor Reading G should fail visibly if it hasn't been previously set */ 17 | private float | int $gScore; 18 | /** @psalm-suppress PropertyNotSetInConstructor Reading H should fail visibly if it hasn't been previously set */ 19 | private float | int $hScore; 20 | 21 | /** 22 | * @param TState $state It refers to the actual user data that represents a node in the user's business logic. 23 | */ 24 | public function __construct(mixed $state) 25 | { 26 | $this->state = $state; 27 | $this->id = $state instanceof NodeIdentifierInterface ? $state->getUniqueNodeId() : serialize($state); 28 | } 29 | 30 | /** 31 | * @return TState Returns the state, which is the user data that represents a node in the user's business logic. 32 | */ 33 | public function getState(): mixed 34 | { 35 | return $this->state; 36 | } 37 | 38 | public function getId(): string 39 | { 40 | return $this->id; 41 | } 42 | 43 | /** 44 | * @param Node $parent 45 | */ 46 | public function setParent(Node $parent): void 47 | { 48 | $this->parent = $parent; 49 | } 50 | 51 | /** 52 | * @return Node | null 53 | */ 54 | public function getParent(): ?Node 55 | { 56 | return $this->parent; 57 | } 58 | 59 | public function getF(): float | int 60 | { 61 | return $this->getG() + $this->getH(); 62 | } 63 | 64 | public function setG(float | int $score): void 65 | { 66 | $this->gScore = $score; 67 | } 68 | 69 | public function getG(): float | int 70 | { 71 | return $this->gScore; 72 | } 73 | 74 | public function setH(float | int $score): void 75 | { 76 | $this->hScore = $score; 77 | } 78 | 79 | public function getH(): float | int 80 | { 81 | return $this->hScore; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Node/NodeIdentifierInterface.php: -------------------------------------------------------------------------------- 1 | */ 13 | private AStar $sut; 14 | /** @var Stub & DomainLogicInterface */ 15 | private Stub | DomainLogicInterface $domainLogic; 16 | 17 | protected function setUp(): void 18 | { 19 | $this->domainLogic = $this->createStub(DomainLogicInterface::class); 20 | 21 | /** @psalm-suppress MixedPropertyTypeCoercion */ 22 | $this->sut = new AStar($this->domainLogic); 23 | } 24 | 25 | public function testShouldFindSolutionIfTheStartAndGoalNodesAreTheSame(): void 26 | { 27 | $startNode = 'foo'; 28 | $goalNode = 'foo'; 29 | 30 | $path = (array) $this->sut->run($startNode, $goalNode); 31 | 32 | $this->assertCount(1, $path); 33 | 34 | $firstAndOnlySolutionNode = reset($path); 35 | 36 | $this->assertSame($startNode, $firstAndOnlySolutionNode); 37 | /** @psalm-suppress RedundantConditionGivenDocblockType */ 38 | $this->assertSame($goalNode, $firstAndOnlySolutionNode); 39 | } 40 | 41 | public function testShouldReturnEmptyPathIfSolutionNotFound(): void 42 | { 43 | $startNode = 'startNode'; 44 | $unreachableGoalNode = 'unreachableGoalNode'; 45 | 46 | $this->domainLogic->method('getAdjacentNodes') 47 | ->willReturn([]); 48 | 49 | $path = $this->sut->run($startNode, $unreachableGoalNode); 50 | 51 | $this->assertCount(0, $path); 52 | } 53 | 54 | public function testSimplePath(): void 55 | { 56 | $startNode = 'startNode'; 57 | $goalNode = 'goalNode'; 58 | $otherNode = 'otherNode'; 59 | 60 | $allNodes = [$startNode, $goalNode, $otherNode]; 61 | 62 | $this->domainLogic->method('getAdjacentNodes') 63 | ->willReturnCallback(function (string $argumentNode) use ($allNodes) { 64 | // The adjacent nodes are all other nodes (not including itself) 65 | return array_filter($allNodes, static fn ($node) => $argumentNode !== $node); 66 | }); 67 | 68 | $this->domainLogic->method('calculateRealCost') 69 | ->willReturn(5); 70 | 71 | $this->domainLogic->method('calculateEstimatedCost') 72 | ->willReturn(2); 73 | 74 | $path = (array) $this->sut->run($startNode, $goalNode); 75 | 76 | $this->assertCount(2, $path); 77 | 78 | $firstSolutionNode = reset($path); 79 | $lastSolutionNode = end($path); 80 | 81 | $this->assertSame($startNode, $firstSolutionNode); 82 | $this->assertSame($goalNode, $lastSolutionNode); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/Benchmark/BenchmarkCommandTest.php: -------------------------------------------------------------------------------- 1 | benchmarkCommand = new BenchmarkCommand(); 18 | 19 | $application = new Application(); 20 | $application->add($this->benchmarkCommand); 21 | 22 | $this->commandTester = new CommandTester($this->benchmarkCommand); 23 | } 24 | 25 | public function testShouldExecuteCorrectly(): void 26 | { 27 | $successfulExitCode = 0; 28 | 29 | $actualExitCode = $this->commandTester->execute([ 30 | 'command' => $this->benchmarkCommand->getName(), 31 | '--size' => [1], 32 | '--iterations' => 1, 33 | ]); 34 | 35 | $output = $this->commandTester->getDisplay(); 36 | 37 | $this->assertSame($successfulExitCode, $actualExitCode); 38 | $this->assertStringContainsString('1x1', $output); 39 | } 40 | 41 | public function testShouldHandleInvalidInput(): void 42 | { 43 | $unsuccessfulExitCode = 1; 44 | $invalidIterationsParameter = 'foobar'; 45 | 46 | $actualExitCode = $this->commandTester->execute([ 47 | 'command' => $this->benchmarkCommand->getName(), 48 | '--size' => [1], 49 | '--iterations' => $invalidIterationsParameter, 50 | ]); 51 | 52 | $output = $this->commandTester->getDisplay(); 53 | 54 | $this->assertSame($unsuccessfulExitCode, $actualExitCode); 55 | $this->assertStringContainsString('[ERROR]', $output); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/Benchmark/BenchmarkRunnerTest.php: -------------------------------------------------------------------------------- 1 | progressBar = $this->createMock(ProgressBarInterface::class); 19 | 20 | $this->sut = new BenchmarkRunner($this->progressBar); 21 | } 22 | 23 | public function testShouldRunTheBenchmark(): void 24 | { 25 | $sizes = [1, 2, 3]; 26 | $iterations = 2; 27 | $seed = null; 28 | 29 | $expectedSteps = $expectedResults = 6; 30 | 31 | $this->progressBar->expects($this->once()) 32 | ->method('start') 33 | ->with($expectedSteps); 34 | 35 | $this->progressBar->expects($this->exactly($expectedSteps)) 36 | ->method('advance'); 37 | 38 | $this->progressBar->expects($this->once()) 39 | ->method('finish'); 40 | 41 | $results = $this->sut->run($sizes, $iterations, $seed); 42 | 43 | $this->assertCount($expectedResults, $results); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Benchmark/InputValidatorTest.php: -------------------------------------------------------------------------------- 1 | input = $this->createMock(InputInterface::class); 53 | 54 | $this->output = $this->createMock(StyleInterface::class); 55 | 56 | $this->sut = new InputValidator($this->output); 57 | } 58 | 59 | public function testShouldValidateCorrectValues(): void 60 | { 61 | $validSizes = ['5', '10']; 62 | $validIterations = '15'; 63 | $validSeed = '123456'; 64 | 65 | $this->setInputExpectations($validSizes, $validIterations, $validSeed); 66 | 67 | $this->output->expects($this->never()) 68 | ->method($this->anything()); 69 | 70 | $result = $this->sut->validate($this->input); 71 | 72 | $this->assertTrue($result); 73 | } 74 | 75 | public function testShouldNotValidateIncorrectValues(): void 76 | { 77 | $invalidSizes = ['a']; 78 | $invalidIterations = 'b'; 79 | $invalidSeed = 'c'; 80 | 81 | $this->setInputExpectations($invalidSizes, $invalidIterations, $invalidSeed); 82 | 83 | $this->output->expects($this->exactly(3)) 84 | ->method('error') 85 | ->withConsecutive( 86 | ['The size must be an integer greater than 0'], 87 | ['The number of iterations must be an integer greater than 0'], 88 | ['The seed must be an integer'], 89 | ); 90 | 91 | $result = $this->sut->validate($this->input); 92 | 93 | $this->assertFalse($result); 94 | } 95 | 96 | /** 97 | * @dataProvider invalidNaturalNumberProvider 98 | */ 99 | public function testShouldNotValidateIncorrectSizes(mixed $invalidSize): void 100 | { 101 | $invalidSizes = [$invalidSize, '10', $invalidSize]; 102 | $validIterations = '15'; 103 | $validSeed = '123456'; 104 | 105 | $this->setInputExpectations($invalidSizes, $validIterations, $validSeed); 106 | 107 | $this->output->expects($this->exactly(2)) 108 | ->method('error') 109 | ->with('The size must be an integer greater than 0'); 110 | 111 | $result = $this->sut->validate($this->input); 112 | 113 | $this->assertFalse($result); 114 | } 115 | 116 | /** 117 | * @dataProvider invalidNaturalNumberProvider 118 | */ 119 | public function testShouldNotValidateIncorrectIterations(mixed $invalidIterations): void 120 | { 121 | $validSizes = ['8']; 122 | $validSeed = '123456'; 123 | 124 | $this->setInputExpectations($validSizes, $invalidIterations, $validSeed); 125 | 126 | $this->output->expects($this->once()) 127 | ->method('error') 128 | ->with('The number of iterations must be an integer greater than 0'); 129 | 130 | $result = $this->sut->validate($this->input); 131 | 132 | $this->assertFalse($result); 133 | } 134 | 135 | /** 136 | * @dataProvider invalidOptionalIntegerProvider 137 | */ 138 | public function testShouldNotValidateIncorrectSeed(mixed $invalidSeed): void 139 | { 140 | $validSizes = ['8']; 141 | $validIterations = '15'; 142 | 143 | $this->setInputExpectations($validSizes, $validIterations, $invalidSeed); 144 | 145 | $this->output->expects($this->once()) 146 | ->method('error') 147 | ->with('The seed must be an integer'); 148 | 149 | $result = $this->sut->validate($this->input); 150 | 151 | $this->assertFalse($result); 152 | } 153 | 154 | public function testShouldValidateOptionalSeed(): void 155 | { 156 | $validSizes = ['8']; 157 | $validIterations = '15'; 158 | $validOptionalSeed = null; 159 | 160 | $this->setInputExpectations($validSizes, $validIterations, $validOptionalSeed); 161 | 162 | $this->output->expects($this->never()) 163 | ->method($this->anything()); 164 | 165 | $result = $this->sut->validate($this->input); 166 | 167 | $this->assertTrue($result); 168 | } 169 | 170 | /** 171 | * @param mixed[] $sizes 172 | * @param mixed $iterations 173 | * @param mixed $seed 174 | */ 175 | private function setInputExpectations(array $sizes, mixed $iterations, mixed $seed): void 176 | { 177 | $this->input->expects($this->exactly(3)) 178 | ->method('getOption') 179 | ->willReturnMap([ 180 | ['size', $sizes], 181 | ['iterations', $iterations], 182 | ['seed', $seed], 183 | ]); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /tests/Benchmark/Result/AggregatedResultTest.php: -------------------------------------------------------------------------------- 1 | assertSame($size, $sut->getSize()); 56 | $this->assertSame($averageDuration, $sut->getAverageDuration()); 57 | $this->assertSame($minimumDuration, $sut->getMinimumDuration()); 58 | $this->assertSame($maximumDuration, $sut->getMaximumDuration()); 59 | $this->assertSame($numberOfSolutions, $sut->getNumberOfSolutions()); 60 | $this->assertSame($numberOfTerrains, $sut->getNumberOfTerrains()); 61 | } 62 | 63 | /** 64 | * @dataProvider invalidNaturalNumberProvider 65 | * @param mixed $invalidSize 66 | * @param class-string<\Throwable> $expectedException 67 | */ 68 | public function testShouldNotSetInvalidSize(mixed $invalidSize, string $expectedException): void 69 | { 70 | $validAverageDuration = 2; 71 | $validMinimumDuration = 1; 72 | $validMaximumDuration = 3; 73 | $validNumberOfSolutions = 4; 74 | $validNumberOfTerrains = 6; 75 | 76 | $this->expectException($expectedException); 77 | 78 | new AggregatedResult( 79 | $invalidSize, 80 | $validAverageDuration, 81 | $validMinimumDuration, 82 | $validMaximumDuration, 83 | $validNumberOfSolutions, 84 | $validNumberOfTerrains 85 | ); 86 | } 87 | 88 | /** 89 | * @dataProvider invalidNonNegativeIntegerProvider 90 | * @param mixed $invalidAverageDuration 91 | * @param class-string<\Throwable> $expectedException 92 | */ 93 | public function testShouldNotSetInvalidAverageDuration( 94 | mixed $invalidAverageDuration, 95 | string $expectedException 96 | ): void { 97 | $validSize = 5; 98 | $validMinimumDuration = 1; 99 | $validMaximumDuration = 3; 100 | $validNumberOfSolutions = 4; 101 | $validNumberOfTerrains = 6; 102 | 103 | $this->expectException($expectedException); 104 | 105 | new AggregatedResult( 106 | $validSize, 107 | $invalidAverageDuration, 108 | $validMinimumDuration, 109 | $validMaximumDuration, 110 | $validNumberOfSolutions, 111 | $validNumberOfTerrains 112 | ); 113 | } 114 | 115 | /** 116 | * @dataProvider invalidNonNegativeIntegerProvider 117 | * @param mixed $invalidMinimumDuration 118 | * @param class-string<\Throwable> $expectedException 119 | */ 120 | public function testShouldNotSetInvalidMinimumDuration( 121 | mixed $invalidMinimumDuration, 122 | string $expectedException 123 | ): void { 124 | $validSize = 5; 125 | $validAverageDuration = 2; 126 | $validMaximumDuration = 3; 127 | $validNumberOfSolutions = 4; 128 | $validNumberOfTerrains = 6; 129 | 130 | $this->expectException($expectedException); 131 | 132 | new AggregatedResult( 133 | $validSize, 134 | $validAverageDuration, 135 | $invalidMinimumDuration, 136 | $validMaximumDuration, 137 | $validNumberOfSolutions, 138 | $validNumberOfTerrains 139 | ); 140 | } 141 | 142 | /** 143 | * @dataProvider invalidNonNegativeIntegerProvider 144 | * @param mixed $invalidMaximumDuration 145 | * @param class-string<\Throwable> $expectedException 146 | */ 147 | public function testShouldNotSetInvalidMaximumDuration( 148 | mixed $invalidMaximumDuration, 149 | string $expectedException 150 | ): void { 151 | $validSize = 5; 152 | $validAverageDuration = 2; 153 | $validMinimumDuration = 1; 154 | $validNumberOfSolutions = 4; 155 | $validNumberOfTerrains = 6; 156 | 157 | $this->expectException($expectedException); 158 | 159 | new AggregatedResult( 160 | $validSize, 161 | $validAverageDuration, 162 | $validMinimumDuration, 163 | $invalidMaximumDuration, 164 | $validNumberOfSolutions, 165 | $validNumberOfTerrains 166 | ); 167 | } 168 | 169 | /** 170 | * @dataProvider invalidNonNegativeIntegerProvider 171 | * @param mixed $invalidNumberOfSolutions 172 | * @param class-string<\Throwable> $expectedException 173 | */ 174 | public function testShouldNotSetInvalidNumberOfSolutions( 175 | mixed $invalidNumberOfSolutions, 176 | string $expectedException 177 | ): void { 178 | $validSize = 5; 179 | $validAverageDuration = 2; 180 | $validMinimumDuration = 1; 181 | $validMaximumDuration = 3; 182 | $validNumberOfTerrains = 6; 183 | 184 | $this->expectException($expectedException); 185 | 186 | new AggregatedResult( 187 | $validSize, 188 | $validAverageDuration, 189 | $validMinimumDuration, 190 | $validMaximumDuration, 191 | $invalidNumberOfSolutions, 192 | $validNumberOfTerrains 193 | ); 194 | } 195 | 196 | /** 197 | * @dataProvider invalidNaturalNumberProvider 198 | * @param mixed $invalidNumberOfTerrains 199 | * @param class-string<\Throwable> $expectedException 200 | */ 201 | public function testShouldNotSetInvalidNumberOfTerrains( 202 | mixed $invalidNumberOfTerrains, 203 | string $expectedException 204 | ): void { 205 | $validSize = 5; 206 | $validAverageDuration = 2; 207 | $validMinimumDuration = 1; 208 | $validMaximumDuration = 3; 209 | $validNumberOfSolutions = 5; 210 | 211 | $this->expectException($expectedException); 212 | 213 | new AggregatedResult( 214 | $validSize, 215 | $validAverageDuration, 216 | $validMinimumDuration, 217 | $validMaximumDuration, 218 | $validNumberOfSolutions, 219 | $invalidNumberOfTerrains 220 | ); 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /tests/Benchmark/Result/ResultAggregatorTest.php: -------------------------------------------------------------------------------- 1 | sut = new ResultAggregator(); 17 | } 18 | 19 | public function testShouldGroupResultsBySize(): void 20 | { 21 | $result1 = $this->createMockResult(5, 4, true); 22 | $result2 = $this->createMockResult(10, 1234, true); 23 | $result3 = $this->createMockResult(5, 20, true); 24 | $result4 = $this->createMockResult(5, 1, true); 25 | 26 | $results = [$result1, $result2, $result3, $result4]; 27 | 28 | $aggregatedResults = $this->sut->process($results); 29 | 30 | $this->assertCount(2, $aggregatedResults); 31 | $this->assertContainsSize(5, $aggregatedResults); 32 | $this->assertContainsSize(10, $aggregatedResults); 33 | } 34 | 35 | public function testShouldCalculateDurations(): void 36 | { 37 | $result1 = $this->createMockResult(5, 4, true); 38 | $result2 = $this->createMockResult(5, 20, true); 39 | $result3 = $this->createMockResult(5, 1, true); 40 | 41 | $results = [$result1, $result2, $result3]; 42 | 43 | $aggregatedResults = $this->sut->process($results); 44 | 45 | $this->assertCount(1, $aggregatedResults); 46 | $aggregatedResult = $aggregatedResults[0]; 47 | $this->assertSame(8, $aggregatedResult->getAverageDuration()); 48 | $this->assertSame(1, $aggregatedResult->getMinimumDuration()); 49 | $this->assertSame(20, $aggregatedResult->getMaximumDuration()); 50 | } 51 | 52 | public function testShouldCalculateNumberOfSolutions(): void 53 | { 54 | $result1 = $this->createMockResult(5, 1, true); 55 | $result2 = $this->createMockResult(5, 1, false); 56 | $result3 = $this->createMockResult(5, 1, true); 57 | 58 | $results = [$result1, $result2, $result3]; 59 | 60 | $aggregatedResults = $this->sut->process($results); 61 | 62 | $this->assertCount(1, $aggregatedResults); 63 | $aggregatedResult = $aggregatedResults[0]; 64 | $this->assertSame(2, $aggregatedResult->getNumberOfSolutions()); 65 | } 66 | 67 | public function testShouldCalculateNumberOfTerrains(): void 68 | { 69 | $result1 = $this->createMockResult(5, 1, true); 70 | $result2 = $this->createMockResult(5, 1, false); 71 | 72 | $results = [$result1, $result2]; 73 | 74 | $aggregatedResults = $this->sut->process($results); 75 | 76 | $this->assertCount(1, $aggregatedResults); 77 | $aggregatedResult = $aggregatedResults[0]; 78 | $this->assertSame(2, $aggregatedResult->getNumberOfTerrains()); 79 | } 80 | 81 | /** 82 | * @param int $needle 83 | * @param AggregatedResult[] $haystack 84 | */ 85 | private function assertContainsSize(int $needle, array $haystack): void 86 | { 87 | foreach ($haystack as $result) { 88 | if ($result->getSize() === $needle) { 89 | return; 90 | } 91 | } 92 | 93 | $this->fail( 94 | 'Failed asserting that the array ' . print_r($haystack, true) . 95 | 'contains a result with the specified size ' . print_r($needle, true) 96 | ); 97 | } 98 | 99 | private function createMockResult(int $size, int $duration, bool $hasSolution): Result 100 | { 101 | $result = $this->createMock(Result::class); 102 | $result->expects($this->atLeastOnce()) 103 | ->method('getSize') 104 | ->willReturn($size); 105 | $result->expects($this->atLeastOnce()) 106 | ->method('getDuration') 107 | ->willReturn($duration); 108 | $result->expects($this->atLeastOnce()) 109 | ->method('hasSolution') 110 | ->willReturn($hasSolution); 111 | 112 | return $result; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /tests/Benchmark/Result/ResultPrinterTest.php: -------------------------------------------------------------------------------- 1 | 8, 'numberOfTerrains' => 8, 'hasSolution' => 'Yes'], 26 | ['numberOfSolutions' => 0, 'numberOfTerrains' => 8, 'hasSolution' => 'No'], 27 | ['numberOfSolutions' => 2, 'numberOfTerrains' => 8, 'hasSolution' => 'Sometimes'], 28 | ]; 29 | } 30 | 31 | protected function setUp(): void 32 | { 33 | $this->output = $this->createMock(StyleInterface::class); 34 | 35 | $this->sut = new ResultPrinter($this->output); 36 | 37 | $this->expectedHeaders = ['Size', 'Avg Duration', 'Min Duration', 'Max Duration', 'Solved?']; 38 | } 39 | 40 | public function testShouldPrintTableHeaders(): void 41 | { 42 | $results = []; 43 | 44 | $this->output->expects($this->once()) 45 | ->method('table') 46 | ->with($this->expectedHeaders); 47 | 48 | $this->sut->display($results); 49 | } 50 | 51 | public function testShouldPrintResult(): void 52 | { 53 | $result = $this->createMockAggregatedResult(5, 4, 2, 6, 10, 10); 54 | 55 | $results = [$result]; 56 | 57 | $expectedRows = [ 58 | ['5x5', '4ms', '2ms', '6ms', 'Yes'], 59 | ]; 60 | 61 | $this->output->expects($this->once()) 62 | ->method('table') 63 | ->with($this->expectedHeaders, $expectedRows); 64 | 65 | $this->sut->display($results); 66 | } 67 | 68 | public function testShouldOrderResultsBySize(): void 69 | { 70 | $result10x10 = $this->createMockAggregatedResult(10, 4, 2, 6, 10, 10); 71 | $result5x5 = $this->createMockAggregatedResult(5, 4, 2, 6, 10, 10); 72 | 73 | $results = [$result10x10, $result5x5]; 74 | 75 | $expectedRows = [ 76 | ['5x5', '4ms', '2ms', '6ms', 'Yes'], 77 | ['10x10', '4ms', '2ms', '6ms', 'Yes'], 78 | ]; 79 | 80 | $this->output->expects($this->once()) 81 | ->method('table') 82 | ->with($this->expectedHeaders, $expectedRows); 83 | 84 | $this->sut->display($results); 85 | } 86 | 87 | /** 88 | * @dataProvider hasSolutionProvider 89 | */ 90 | public function testShouldFormatHasSolution( 91 | int $numberOfSolutions, 92 | int $numberOfTerrains, 93 | string $expectedHasSolution 94 | ): void { 95 | $result = $this->createMockAggregatedResult(5, 4, 2, 6, $numberOfSolutions, $numberOfTerrains); 96 | 97 | $results = [$result]; 98 | 99 | $expectedRows = [ 100 | ['5x5', '4ms', '2ms', '6ms', $expectedHasSolution], 101 | ]; 102 | 103 | $this->output->expects($this->once()) 104 | ->method('table') 105 | ->with($this->expectedHeaders, $expectedRows); 106 | 107 | $this->sut->display($results); 108 | } 109 | 110 | private function createMockAggregatedResult( 111 | int $size, 112 | int $averageDuration, 113 | int $minimumDuration, 114 | int $maximumDuration, 115 | int $numberOfSolutions, 116 | int $numberOfTerrains 117 | ): AggregatedResult { 118 | $result = $this->createMock(AggregatedResult::class); 119 | $result->expects($this->atLeastOnce()) 120 | ->method('getSize') 121 | ->willReturn($size); 122 | $result->expects($this->atLeastOnce()) 123 | ->method('getAverageDuration') 124 | ->willReturn($averageDuration); 125 | $result->expects($this->atLeastOnce()) 126 | ->method('getMinimumDuration') 127 | ->willReturn($minimumDuration); 128 | $result->expects($this->atLeastOnce()) 129 | ->method('getMaximumDuration') 130 | ->willReturn($maximumDuration); 131 | $result->expects($this->atLeastOnce()) 132 | ->method('getNumberOfSolutions') 133 | ->willReturn($numberOfSolutions); 134 | $result->expects($this->atLeastOnce()) 135 | ->method('getNumberOfTerrains') 136 | ->willReturn($numberOfTerrains); 137 | 138 | return $result; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /tests/Benchmark/Result/ResultTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expectedSize, $sut->getSize()); 60 | $this->assertSame($expectedDuration, $sut->getDuration()); 61 | $this->assertSame($hasSolution, $sut->hasSolution()); 62 | } 63 | 64 | /** 65 | * @dataProvider invalidNaturalNumberProvider 66 | * @param mixed $invalidSize 67 | * @param class-string<\Throwable> $expectedException 68 | * @param string $expectedExceptionMessage 69 | */ 70 | public function testShouldNotSetInvalidSize( 71 | mixed $invalidSize, 72 | string $expectedException, 73 | string $expectedExceptionMessage 74 | ): void { 75 | $validDuration = 200; 76 | $validHasSolution = true; 77 | 78 | $this->expectException($expectedException); 79 | $this->expectExceptionMessage($expectedExceptionMessage); 80 | 81 | new Result($invalidSize, $validDuration, $validHasSolution); 82 | } 83 | 84 | /** 85 | * @dataProvider invalidNonNegativeIntegerProvider 86 | * @param mixed $invalidDuration 87 | * @param class-string<\Throwable> $expectedException 88 | * @param string $expectedExceptionMessage 89 | */ 90 | public function testShouldNotSetInvalidDuration( 91 | mixed $invalidDuration, 92 | string $expectedException, 93 | string $expectedExceptionMessage 94 | ): void { 95 | $validSize = '5'; 96 | $validHasSolution = false; 97 | 98 | $this->expectException($expectedException); 99 | $this->expectExceptionMessage($expectedExceptionMessage); 100 | 101 | /** 102 | * @phpstan-ignore-next-line 103 | * @psalm-suppress InvalidScalarArgument 104 | * A numeric string for the size is a valid user input 105 | */ 106 | new Result($validSize, $invalidDuration, $validHasSolution); 107 | } 108 | 109 | public function testShouldReturnABooleanTypeWhenRetrievingHasSolution(): void 110 | { 111 | $size = 2; 112 | $duration = 3; 113 | $hasSolution = 1; 114 | 115 | /** 116 | * @phpstan-ignore-next-line 117 | * @psalm-suppress InvalidScalarArgument 118 | * We actually want to pass an integer for $hasSolution as part of this test 119 | */ 120 | $sut = new Result($size, $duration, $hasSolution); 121 | 122 | $this->assertTrue($sut->hasSolution()); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/Benchmark/SymfonyProgressBarTest.php: -------------------------------------------------------------------------------- 1 | createStub(OutputFormatterInterface::class); 22 | $outputFormatter->method('isDecorated') 23 | ->willReturn(false); 24 | 25 | $this->output = $this->createMock(OutputInterface::class); 26 | $this->output->method('getFormatter') 27 | ->willReturn($outputFormatter); 28 | 29 | // The Symfony Progress Bar cannot be mocked as it is a final class and it doesn't implement an interface 30 | $symfonyProgressBar = new ProgressBar($this->output); 31 | 32 | $this->sut = new SymfonyProgressBar($symfonyProgressBar); 33 | } 34 | 35 | public function testShouldImplementTheProgressBarInterface(): void 36 | { 37 | $this->assertInstanceOf(ProgressBarInterface::class, $this->sut); 38 | } 39 | 40 | public function testShouldDelegateTheStartMethod(): void 41 | { 42 | $this->output->expects($this->atLeastOnce()) 43 | ->method('write'); 44 | 45 | $this->sut->start(5); 46 | } 47 | 48 | public function testShouldDelegateTheAdvanceMethod(): void 49 | { 50 | $this->output->expects($this->atLeastOnce()) 51 | ->method('write'); 52 | 53 | $this->sut->advance(); 54 | } 55 | 56 | public function testShouldDelegateTheFinishMethod(): void 57 | { 58 | $this->output->expects($this->never()) 59 | ->method('write'); 60 | 61 | $this->sut->finish(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/Benchmark/TerrainGeneratorTest.php: -------------------------------------------------------------------------------- 1 | sut = new TerrainGenerator(); 42 | } 43 | 44 | public function testShouldGenerateTerrain(): void 45 | { 46 | $rows = 3; 47 | $columns = 5; 48 | 49 | $result = $this->sut->generate($rows, $columns); 50 | 51 | $this->assertSame($rows, $result->getTotalRows()); 52 | $this->assertSame($columns, $result->getTotalColumns()); 53 | } 54 | 55 | /** 56 | * @dataProvider invalidNaturalNumberProvider 57 | * @param mixed $invalidRows 58 | * @param class-string<\Throwable> $expectedException 59 | */ 60 | public function testShouldNotGenerateWithInvalidRows(mixed $invalidRows, string $expectedException): void 61 | { 62 | $columns = 5; 63 | 64 | $this->expectException($expectedException); 65 | 66 | $this->sut->generate($invalidRows, $columns); 67 | } 68 | 69 | /** 70 | * @dataProvider invalidNaturalNumberProvider 71 | * @param mixed $invalidColumns 72 | * @param class-string<\Throwable> $expectedException 73 | */ 74 | public function testShouldNotGenerateWithInvalidColumns(mixed $invalidColumns, string $expectedException): void 75 | { 76 | $rows = 3; 77 | 78 | $this->expectException($expectedException); 79 | 80 | $this->sut->generate($rows, $invalidColumns); 81 | } 82 | 83 | /** 84 | * @dataProvider invalidOptionalIntegerProvider 85 | * @param mixed $invalidSeed 86 | * @param class-string<\Throwable> $expectedException 87 | */ 88 | public function testShouldNotGenerateWithInvalidSeed(mixed $invalidSeed, string $expectedException): void 89 | { 90 | $rows = 3; 91 | $columns = 5; 92 | 93 | $this->expectException($expectedException); 94 | 95 | $this->sut->generate($rows, $columns, $invalidSeed); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/Example/Graph/CoordinateTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expectedX, $sut->getX()); 53 | $this->assertSame($expectedY, $sut->getY()); 54 | } 55 | 56 | /** 57 | * @dataProvider invalidPointProvider 58 | */ 59 | public function testShouldNotSetInvalidPoint(mixed $x, mixed $y): void 60 | { 61 | $this->expectException(\TypeError::class); 62 | 63 | new Coordinate($x, $y); 64 | } 65 | 66 | /** 67 | * @dataProvider validPointProvider 68 | */ 69 | public function testShouldGenerateAnId(mixed $x, mixed $y): void 70 | { 71 | /** @psalm-suppress MixedOperand $x and $y will be an integer or a string representing an integer */ 72 | $expectedId = $x . 'x' . $y; 73 | 74 | $sut = new Coordinate($x, $y); 75 | 76 | $this->assertSame($expectedId, $sut->getId()); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/Example/Graph/DomainLogicTest.php: -------------------------------------------------------------------------------- 1 | sut = new DomainLogic($graph); 29 | } 30 | 31 | public function testShouldGenerateAdjacentNodes(): void 32 | { 33 | $node = new Coordinate(0, 0); 34 | $expectedAdjacentNodes = [ 35 | new Coordinate(2, 5), 36 | new Coordinate(6, 4) 37 | ]; 38 | 39 | $adjacentNodes = $this->sut->getAdjacentNodes($node); 40 | 41 | $this->assertCount(count($expectedAdjacentNodes), $adjacentNodes); 42 | 43 | foreach ($expectedAdjacentNodes as $expectedNode) { 44 | $this->assertContainsCoordinate($expectedNode, $adjacentNodes); 45 | } 46 | } 47 | 48 | public function testShouldCalculateRealCost(): void 49 | { 50 | $expectedCost = 3.2; 51 | 52 | $node = new Coordinate(3, 3); 53 | $adjacentNode = new Coordinate(6, 4); 54 | 55 | $cost = $this->sut->calculateRealCost($node, $adjacentNode); 56 | 57 | $this->assertSame($expectedCost, $cost); 58 | } 59 | 60 | public function testShouldNotCalculateTheRealCostBetweenTwoUnlinkedNodes(): void 61 | { 62 | $node = new Coordinate(6, 4); 63 | $nonAdjacentNode = new Coordinate(3, 3); 64 | 65 | $this->expectException(\DomainException::class); 66 | $this->expectExceptionMessage('not linked'); 67 | 68 | $this->sut->calculateRealCost($node, $nonAdjacentNode); 69 | } 70 | 71 | public function testShouldCalculateEstimatedCost(): void 72 | { 73 | $expectedCost = 20.2237484162; 74 | $maximumImprecisionAllowed = 0.0001; 75 | 76 | $startNode = new Coordinate(-5, 6); 77 | $destinationNode = new Coordinate(15, 9); 78 | 79 | $cost = $this->sut->calculateEstimatedCost($startNode, $destinationNode); 80 | 81 | $this->assertEqualsWithDelta($expectedCost, $cost, $maximumImprecisionAllowed); 82 | } 83 | 84 | public function testShouldGetRightSolution(): void 85 | { 86 | $start = new Coordinate(0, 0); 87 | $goal = new Coordinate(10, 10); 88 | 89 | $expectedSolution = [ 90 | new Coordinate(0, 0), 91 | new Coordinate(2, 5), 92 | new Coordinate(3, 3), 93 | new Coordinate(6, 4), 94 | new Coordinate(10, 10) 95 | ]; 96 | 97 | $expectedSolutionIds = array_map(static fn (Coordinate $coordinate) => $coordinate->getId(), $expectedSolution); 98 | 99 | $aStar = new AStar($this->sut); 100 | $solution = $aStar->run($start, $goal); 101 | 102 | $this->assertCount(count($expectedSolution), $solution); 103 | 104 | $solutionIds = array_map(static fn (Coordinate $coordinate) => $coordinate->getId(), (array) ($solution)); 105 | 106 | $this->assertEquals($expectedSolutionIds, $solutionIds); 107 | } 108 | 109 | public function testShouldGetSolutionWithNodesFormingCircularPaths(): void 110 | { 111 | $nodes = [ 112 | 'start' => new Coordinate(0, 0), 113 | 'intermediate' => new Coordinate(2, 5), 114 | 'goal' => new Coordinate(6, 4) 115 | ]; 116 | 117 | $links = [ 118 | new Link($nodes['start'], $nodes['intermediate'], 6), 119 | new Link($nodes['intermediate'], $nodes['start'], 6), 120 | 121 | new Link($nodes['intermediate'], $nodes['goal'], 23), 122 | new Link($nodes['goal'], $nodes['intermediate'], 23), 123 | ]; 124 | 125 | $graph = new Graph($links); 126 | 127 | $expectedSolution = [ 128 | $nodes['start'], 129 | $nodes['intermediate'], 130 | $nodes['goal'] 131 | ]; 132 | 133 | $expectedSolutionIds = array_map(static fn (Coordinate $coordinate) => $coordinate->getId(), $expectedSolution); 134 | 135 | $this->sut = new DomainLogic($graph); 136 | 137 | $aStar = new AStar($this->sut); 138 | $solution = $aStar->run($nodes['start'], $nodes['goal']); 139 | 140 | $solutionIds = array_map(static fn (Coordinate $coordinate) => $coordinate->getId(), (array) ($solution)); 141 | 142 | $this->assertEquals($expectedSolutionIds, $solutionIds); 143 | } 144 | 145 | /** 146 | * @param Coordinate $needle 147 | * @param Coordinate[] $haystack 148 | */ 149 | private function assertContainsCoordinate(Coordinate $needle, iterable $haystack): void 150 | { 151 | foreach ($haystack as $node) { 152 | if ($needle->getId() === $node->getId()) { 153 | return; 154 | } 155 | } 156 | 157 | $this->fail( 158 | 'Failed asserting that the array ' . print_r($haystack, true) . 159 | 'contains the specified coordinate ' . print_r($needle, true) 160 | ); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /tests/Example/Graph/GraphTest.php: -------------------------------------------------------------------------------- 1 | sut = new Graph(); 17 | } 18 | 19 | public function testShouldAddLink(): void 20 | { 21 | $source = new Coordinate(0, 0); 22 | $destination = new Coordinate(1, 1); 23 | $distance = 123.45; 24 | $link = new Link($source, $destination, $distance); 25 | 26 | $this->assertFalse($this->sut->hasLink($source, $destination)); 27 | $this->assertNull($this->sut->getLink($source, $destination)); 28 | 29 | $this->sut->addLink($link); 30 | 31 | $this->assertTrue($this->sut->hasLink($source, $destination)); 32 | $this->assertSame($link, $this->sut->getLink($source, $destination)); 33 | } 34 | 35 | public function testShouldOverwriteLinksWithSameSourceAndDestination(): void 36 | { 37 | $source = new Coordinate(0, 0); 38 | $destination = new Coordinate(1, 1); 39 | 40 | $distance1 = 3; 41 | $link1 = new Link($source, $destination, $distance1); 42 | 43 | $distance2 = 200; 44 | $link2 = new Link($source, $destination, $distance2); 45 | 46 | $this->assertFalse($this->sut->hasLink($source, $destination)); 47 | $this->assertNull($this->sut->getLink($source, $destination)); 48 | 49 | $this->sut->addLink($link1); 50 | 51 | $this->assertTrue($this->sut->hasLink($source, $destination)); 52 | $this->assertEquals($distance1, $this->sut->getLink($source, $destination)?->getDistance()); 53 | 54 | $this->sut->addLink($link2); 55 | 56 | $this->assertTrue($this->sut->hasLink($source, $destination)); 57 | $this->assertNotEquals($distance1, $this->sut->getLink($source, $destination)?->getDistance()); 58 | $this->assertEquals($distance2, $this->sut->getLink($source, $destination)?->getDistance()); 59 | } 60 | 61 | public function testShouldSetLinksInConstructor(): void 62 | { 63 | $source1 = new Coordinate(0, 1); 64 | $destination1 = new Coordinate(2, 3); 65 | $distance1 = 5.5; 66 | 67 | $source2 = new Coordinate(4, 5); 68 | $destination2 = new Coordinate(6, 7); 69 | $distance2 = 27.89; 70 | 71 | $links = [ 72 | new Link($source1, $destination1, $distance1), 73 | new Link($source2, $destination2, $distance2) 74 | ]; 75 | 76 | $this->sut = new Graph($links); 77 | 78 | $this->assertSame($distance1, $this->sut->getLink($source1, $destination1)?->getDistance()); 79 | $this->assertSame($distance2, $this->sut->getLink($source2, $destination2)?->getDistance()); 80 | } 81 | 82 | public function testShouldGetDirectSuccessors(): void 83 | { 84 | $nodeA = new Coordinate(0, 0); 85 | $nodeB = new Coordinate(1, 1); 86 | $nodeC = new Coordinate(2, 2); 87 | $nodeD = new Coordinate(3, 3); 88 | $distance = 1; 89 | 90 | $this->sut->addLink(new Link($nodeA, $nodeB, $distance)); 91 | $this->sut->addLink(new Link($nodeA, $nodeC, $distance)); 92 | $this->sut->addLink(new Link($nodeB, $nodeD, $distance)); 93 | 94 | $nodeADirectSuccessors = $this->sut->getDirectSuccessors($nodeA); 95 | $nodeBDirectSuccessors = $this->sut->getDirectSuccessors($nodeB); 96 | $nodeCDirectSuccessors = $this->sut->getDirectSuccessors($nodeC); 97 | $nodeDDirectSuccessors = $this->sut->getDirectSuccessors($nodeD); 98 | 99 | $this->assertCount(2, $nodeADirectSuccessors); 100 | $this->assertCount(1, $nodeBDirectSuccessors); 101 | $this->assertCount(0, $nodeCDirectSuccessors); 102 | $this->assertCount(0, $nodeDDirectSuccessors); 103 | 104 | foreach ($nodeADirectSuccessors as $successor) { 105 | $this->assertTrue($successor->getId() === $nodeB->getId() || $successor->getId() === $nodeC->getId()); 106 | } 107 | 108 | $this->assertSame($nodeD->getId(), $nodeBDirectSuccessors[0]->getId()); 109 | } 110 | 111 | public function testShouldGetEmptyArrayAsDirectSuccessorsIfNodeDoesNotExist(): void 112 | { 113 | $nonExistentNode = new Coordinate(0, 0); 114 | 115 | $this->assertCount(0, $this->sut->getDirectSuccessors($nonExistentNode)); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tests/Example/Graph/LinkTest.php: -------------------------------------------------------------------------------- 1 | createStub(Coordinate::class); 48 | $destination = $this->createStub(Coordinate::class); 49 | 50 | $sut = new Link($source, $destination, $distance); 51 | 52 | $this->assertSame($source, $sut->getSource()); 53 | $this->assertSame($destination, $sut->getDestination()); 54 | $this->assertSame($expectedDistance, $sut->getDistance()); 55 | } 56 | 57 | /** 58 | * @dataProvider invalidDistanceProvider 59 | * @param mixed $distance 60 | * @param class-string<\Throwable> $expectedException 61 | * @param string $expectedExceptionMessage 62 | */ 63 | public function testShouldNotSetInvalidDistance( 64 | mixed $distance, 65 | string $expectedException, 66 | string $expectedExceptionMessage 67 | ): void { 68 | $source = $this->createStub(Coordinate::class); 69 | $destination = $this->createStub(Coordinate::class); 70 | 71 | $this->expectException($expectedException); 72 | $this->expectExceptionMessage($expectedExceptionMessage); 73 | 74 | new Link($source, $destination, $distance); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/Example/Graph/SequencePrinterTest.php: -------------------------------------------------------------------------------- 1 | graph = new Graph($links); 26 | } 27 | 28 | public function testShouldPrintANodeSequence(): void 29 | { 30 | $sequence = [ 31 | new Coordinate(0, 0), 32 | new Coordinate(2, 5), 33 | new Coordinate(3, 3), 34 | new Coordinate(6, 4), 35 | new Coordinate(10, 10) 36 | ]; 37 | 38 | $expectedOutput = "(0, 0) => (2, 5) => (3, 3) => (6, 4) => (10, 10)\nTotal cost: 22.7"; 39 | 40 | $sut = new SequencePrinter($this->graph, $sequence); 41 | 42 | $sut->printSequence(); 43 | 44 | $this->expectOutputString($expectedOutput); 45 | } 46 | 47 | public function testShouldPrintMessageIfSequenceIsEmpty(): void 48 | { 49 | $expectedOutput = 'Total cost: 0'; 50 | 51 | $sut = new SequencePrinter($this->graph, []); 52 | 53 | $sut->printSequence(); 54 | 55 | $this->expectOutputString($expectedOutput); 56 | } 57 | 58 | public function testShouldPrintSequenceEvenIfItOnlyHasOneNode(): void 59 | { 60 | $sequence = [new Coordinate(3, 3)]; 61 | 62 | $expectedOutput = "(3, 3)\nTotal cost: 0"; 63 | 64 | $sut = new SequencePrinter($this->graph, $sequence); 65 | 66 | $sut->printSequence(); 67 | 68 | $this->expectOutputString($expectedOutput); 69 | } 70 | 71 | public function testShouldThrowExceptionIfTheSequenceIsNotConnected(): void 72 | { 73 | $sequence = [ 74 | new Coordinate(0, 0), 75 | new Coordinate(2, 5), 76 | new Coordinate(99999, 99999), 77 | ]; 78 | 79 | $sut = new SequencePrinter($this->graph, $sequence); 80 | 81 | $this->expectException(\RuntimeException::class); 82 | $this->expectExceptionMessage('Some of the nodes in the provided sequence are not connected'); 83 | $this->expectOutputString(''); 84 | 85 | $sut->printSequence(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/Example/Terrain/DomainLogicTest.php: -------------------------------------------------------------------------------- 1 | new Position(2, 0), 23 | 'adjacent' => [ 24 | new Position(1, 0), 25 | new Position(1, 1), 26 | new Position(2, 1), 27 | ] 28 | ], 29 | [ 30 | 'node' => new Position(0, 2), 31 | 'adjacent' => [ 32 | new Position(0, 1), 33 | new Position(1, 1), 34 | new Position(1, 2), 35 | ] 36 | ], 37 | [ 38 | 'node' => new Position(1, 1), 39 | 'adjacent' => [ 40 | new Position(0, 0), 41 | new Position(0, 1), 42 | new Position(0, 2), 43 | new Position(1, 0), 44 | new Position(1, 2), 45 | new Position(2, 0), 46 | new Position(2, 1), 47 | new Position(2, 2), 48 | ] 49 | ] 50 | ]; 51 | } 52 | 53 | protected function setUp(): void 54 | { 55 | $terrainCost = new TerrainCost([ 56 | [1, 3, 5], 57 | [2, 8, 1], 58 | [1, 1, 1] 59 | ]); 60 | 61 | $this->sut = new DomainLogic($terrainCost); 62 | } 63 | 64 | public function testShouldImplementTheDomainLogicInterface(): void 65 | { 66 | $this->assertInstanceOf(DomainLogicInterface::class, $this->sut); 67 | } 68 | 69 | /** 70 | * @dataProvider adjacentNodesProvider 71 | * @param Position $node 72 | * @param Position[] $expectedAdjacentNodes 73 | */ 74 | public function testShouldGenerateAdjacentNodes(Position $node, array $expectedAdjacentNodes): void 75 | { 76 | $adjacentNodes = $this->sut->getAdjacentNodes($node); 77 | 78 | $this->assertCount(count($expectedAdjacentNodes), $adjacentNodes); 79 | 80 | foreach ($expectedAdjacentNodes as $expectedPosition) { 81 | $this->assertContainsPosition($expectedPosition, $adjacentNodes); 82 | } 83 | } 84 | 85 | public function testShouldCalculateRealCost(): void 86 | { 87 | $expectedCost = 3; 88 | 89 | $node = new Position(1, 0); 90 | $adjacentNode = new Position(0, 1); 91 | 92 | $cost = $this->sut->calculateRealCost($node, $adjacentNode); 93 | 94 | $this->assertSame($expectedCost, $cost); 95 | } 96 | 97 | public function testTheCostBetweenNonAdjacentNodesShouldBeInfinite(): void 98 | { 99 | $expectedCost = TerrainCost::INFINITE; 100 | 101 | $node = new Position(0, 0); 102 | $nonAdjacentNode = new Position(0, 2); 103 | 104 | $cost = $this->sut->calculateRealCost($node, $nonAdjacentNode); 105 | 106 | $this->assertSame($expectedCost, $cost); 107 | } 108 | 109 | public function testShouldCalculateEstimatedCost(): void 110 | { 111 | $expectedCost = sqrt(5); 112 | $maximumImprecisionAllowed = 0.0001; 113 | 114 | $startNode = new Position(1, 0); 115 | $destinationNode = new Position(0, 2); 116 | 117 | $cost = $this->sut->calculateEstimatedCost($startNode, $destinationNode); 118 | 119 | $this->assertEqualsWithDelta($expectedCost, $cost, $maximumImprecisionAllowed); 120 | } 121 | 122 | public function testShouldNotGenerateNewNodesAfterEveryCallToGetAdjacentNodes(): void 123 | { 124 | $node = new Position(0, 0); 125 | 126 | $adjacentNodes = $this->sut->getAdjacentNodes($node); 127 | $sameAdjacentNodes = $this->sut->getAdjacentNodes($node); 128 | 129 | $this->assertEquals($adjacentNodes, $sameAdjacentNodes); 130 | } 131 | 132 | /** 133 | * @param Position $needle 134 | * @param Position[] $haystack 135 | */ 136 | private function assertContainsPosition(Position $needle, iterable $haystack): void 137 | { 138 | foreach ($haystack as $position) { 139 | if ($needle->isEqualTo($position)) { 140 | return; 141 | } 142 | } 143 | 144 | $this->fail( 145 | 'Failed asserting that the array ' . print_r($haystack, true) . 146 | 'contains the specified position ' . print_r($needle, true) 147 | ); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /tests/Example/Terrain/PositionTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expectedRow, $sut->getRow()); 85 | $this->assertSame($expectedColumn, $sut->getColumn()); 86 | } 87 | 88 | /** 89 | * @dataProvider invalidPointProvider 90 | * @param mixed $row 91 | * @param mixed $column 92 | * @param class-string<\Throwable> $expectedException 93 | * @param string $expectedExceptionMessage 94 | */ 95 | public function testShouldNotSetInvalidPoint( 96 | mixed $row, 97 | mixed $column, 98 | string $expectedException, 99 | string $expectedExceptionMessage 100 | ): void { 101 | $this->expectException($expectedException); 102 | $this->expectExceptionMessage($expectedExceptionMessage); 103 | 104 | new Position($row, $column); 105 | } 106 | 107 | public function testShouldBeEqualToAnotherPositionWhenTheirRowsAndColumnsAreTheSame(): void 108 | { 109 | $position = new Position(1, 2); 110 | $samePosition = new Position(1, 2); 111 | 112 | $this->assertTrue($position->isEqualTo($samePosition)); 113 | $this->assertTrue($samePosition->isEqualTo($position)); 114 | } 115 | 116 | public function testShouldBeDifferentToAnotherPositionWhenTheirRowsAreDifferent(): void 117 | { 118 | $sameColumn = 5; 119 | 120 | $position = new Position(3, $sameColumn); 121 | $differentPosition = new Position(4, $sameColumn); 122 | 123 | $this->assertFalse($position->isEqualTo($differentPosition)); 124 | $this->assertFalse($differentPosition->isEqualTo($position)); 125 | } 126 | 127 | public function testShouldBeDifferentToAnotherPositionWhenTheirColumnsAreDifferent(): void 128 | { 129 | $sameRow = 8; 130 | 131 | $position = new Position($sameRow, 6); 132 | $differentPosition = new Position($sameRow, 7); 133 | 134 | $this->assertFalse($position->isEqualTo($differentPosition)); 135 | $this->assertFalse($differentPosition->isEqualTo($position)); 136 | } 137 | 138 | /** 139 | * @dataProvider adjacentPointProvider 140 | */ 141 | public function testShouldIdentifyAdjacentPositions(Position $position, Position $adjacent): void 142 | { 143 | $this->assertTrue($position->isAdjacentTo($adjacent)); 144 | $this->assertTrue($adjacent->isAdjacentTo($position)); 145 | } 146 | 147 | /** 148 | * @dataProvider nonAdjacentPointProvider 149 | */ 150 | public function testShouldIdentifyNonAdjacentPositions(Position $position, Position $nonAdjacent): void 151 | { 152 | $this->assertFalse($position->isAdjacentTo($nonAdjacent)); 153 | $this->assertFalse($nonAdjacent->isAdjacentTo($position)); 154 | } 155 | 156 | public function testShouldImplementTheNodeIdentifierInterface(): void 157 | { 158 | $this->assertInstanceOf(NodeIdentifierInterface::class, new Position(0, 0)); 159 | } 160 | 161 | public function testShouldSetItsUniqueNodeIdBasedOnItsRowAndColumn(): void 162 | { 163 | $row = 5; 164 | $column = 8; 165 | $expectedNodeId = '5x8'; 166 | 167 | $sut = new Position($row, $column); 168 | 169 | $this->assertSame($expectedNodeId, $sut->getUniqueNodeId()); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /tests/Example/Terrain/SequencePrinterTest.php: -------------------------------------------------------------------------------- 1 | sut = new SequencePrinter($terrainCost, $sequence); 80 | } 81 | 82 | public function testShouldHaveDefaultEmptyTileToken(): void 83 | { 84 | $defaultEmptyTileToken = '-'; 85 | 86 | $this->assertSame($defaultEmptyTileToken, $this->sut->getEmptyTileToken()); 87 | } 88 | 89 | /** 90 | * @dataProvider validStringProvider 91 | */ 92 | public function testShouldSetValidEmptyTileToken(string $token): void 93 | { 94 | $this->sut->setEmptyTileToken($token); 95 | 96 | $this->assertSame($token, $this->sut->getEmptyTileToken()); 97 | } 98 | 99 | public function testShouldHaveDefaultTileSize(): void 100 | { 101 | $defaultTileSize = 3; 102 | 103 | $this->assertSame($defaultTileSize, $this->sut->getTileSize()); 104 | } 105 | 106 | /** 107 | * @dataProvider validNaturalNumberProvider 108 | */ 109 | public function testShouldSetValidTileSize(mixed $tileSize): void 110 | { 111 | $expectedTileSize = (int) $tileSize; 112 | 113 | $this->sut->setTileSize($tileSize); 114 | 115 | $this->assertSame($expectedTileSize, $this->sut->getTileSize()); 116 | } 117 | 118 | /** 119 | * @dataProvider invalidNaturalNumberForTileProvider 120 | * @param mixed $invalidTileSize 121 | * @param class-string<\Throwable> $expectedException 122 | * @param string $expectedExceptionMessage 123 | */ 124 | public function testShouldNotSetInvalidTileSize( 125 | mixed $invalidTileSize, 126 | string $expectedException, 127 | string $expectedExceptionMessage 128 | ): void { 129 | $this->expectException($expectedException); 130 | $this->expectExceptionMessage($expectedExceptionMessage); 131 | 132 | $this->sut->setTileSize($invalidTileSize); 133 | } 134 | 135 | public function testShouldHaveDefaultPadToken(): void 136 | { 137 | $defaultPadToken = ' '; 138 | 139 | $this->assertSame($defaultPadToken, $this->sut->getPadToken()); 140 | } 141 | 142 | /** 143 | * @dataProvider validStringProvider 144 | */ 145 | public function testShouldSetValidPadToken(string $token): void 146 | { 147 | $this->sut->setPadToken($token); 148 | 149 | $this->assertSame($token, $this->sut->getPadToken()); 150 | } 151 | 152 | public function testShouldPrintANodeSequence(): void 153 | { 154 | $expectedOutput = <<sut->printSequence(); 164 | 165 | $this->expectOutputString($expectedOutput); 166 | } 167 | 168 | public function testShouldPrintANodeSequenceWithNonDefaultValues(): void 169 | { 170 | $padToken = 'x'; 171 | $emptyTileToken = 'o'; 172 | $tileSize = 5; 173 | 174 | $expectedOutput = <<sut->setPadToken($padToken); 184 | $this->sut->setEmptyTileToken($emptyTileToken); 185 | $this->sut->setTileSize($tileSize); 186 | 187 | $this->sut->printSequence(); 188 | 189 | $this->expectOutputString($expectedOutput); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /tests/Example/Terrain/StaticExampleTest.php: -------------------------------------------------------------------------------- 1 | expectOutputString($expectedOutput); 34 | } 35 | 36 | public function testShouldPrintSolution2(): void 37 | { 38 | $terrainCost = new TerrainCost([ 39 | [1, 4, 1], 40 | [1, 5, 1], 41 | [1, 6, 1], 42 | [1, 7, 1], 43 | ]); 44 | 45 | $start = new Position(0, 0); 46 | $goal = new Position(0, 2); 47 | 48 | $expectedOutput = <<expectOutputString($expectedOutput); 58 | } 59 | 60 | public function testShouldPrintSolution3(): void 61 | { 62 | $terrainCost = new TerrainCost([ 63 | [3, 2, 3, 6, 1], 64 | [1, 3, 4, 2, 1], 65 | [3, 2, 3, 4, 3], 66 | [1, 1, 5, 3, 1], 67 | ]); 68 | 69 | $start = new Position(0, 0); 70 | $goal = new Position(3, 4); 71 | 72 | $expectedOutput = <<expectOutputString($expectedOutput); 82 | } 83 | 84 | public function testShouldPrintSolution4(): void 85 | { 86 | $terrainCost = new TerrainCost([ 87 | [1, 1, 9, 1, 1], 88 | [1, 1, 9, 1, 1], 89 | [1, 1, 9, 1, 1], 90 | [1, 1, 1, 1, 1], 91 | ]); 92 | 93 | $start = new Position(1, 1); 94 | $goal = new Position(1, 3); 95 | 96 | $expectedOutput = <<expectOutputString($expectedOutput); 106 | } 107 | 108 | public function testShouldPrintSolution5(): void 109 | { 110 | $terrainCost = new TerrainCost([ 111 | [1, 4, 4, 5, 1], 112 | [1, 2, 3, 5, 1], 113 | [1, 4, 4, 5, 1], 114 | [1, 1, 1, 1, 1], 115 | ]); 116 | 117 | $start = new Position(0, 0); 118 | $goal = new Position(0, 4); 119 | 120 | $expectedOutput = <<expectOutputString($expectedOutput); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /tests/Example/Terrain/TerrainCostTest.php: -------------------------------------------------------------------------------- 1 | [ 17 | [ 18 | [1, 5, 0], 19 | [2, 2, 2], 20 | [2, 8, 9] 21 | ] 22 | ], 23 | '4x2 terrain' => [ 24 | [ 25 | [1, 2], 26 | [0, 0], 27 | [3, PHP_INT_MAX], 28 | [5, 3] 29 | ] 30 | ], 31 | 'terrain with numbers of string type' => [ 32 | [ 33 | ['2', '3', '0'], 34 | ['1', 2, '3'] 35 | ] 36 | ], 37 | 'associative array' => [ 38 | [ 39 | 'first row' => ['first column' => 2, 'second column' => 3], 40 | 'next row' => ['foo' => 0, 'bar' => 5] 41 | ] 42 | ] 43 | ]; 44 | } 45 | 46 | /** 47 | * @return mixed[][][] 48 | */ 49 | public function emptyTerrainProvider(): array 50 | { 51 | return [ 52 | 'no rows nor columns' => [ 53 | [] 54 | ], 55 | 'no columns' => [ 56 | [ 57 | [] 58 | ] 59 | ] 60 | ]; 61 | } 62 | 63 | /** 64 | * @return mixed[][][][] 65 | */ 66 | public function invalidTerrainCostsProvider(): array 67 | { 68 | return [ 69 | 'costs of type float' => [ 70 | [ 71 | [2.3, 2], 72 | [1, 0] 73 | ] 74 | ], 75 | 'invalid cost type' => [ 76 | [ 77 | [false] 78 | ] 79 | ] 80 | ]; 81 | } 82 | 83 | /** 84 | * @return int[][][][] 85 | */ 86 | public function nonRectangularTerrainProvider(): array 87 | { 88 | return [ 89 | [ 90 | [ 91 | [0, 0, 0], 92 | [0, 0, 0], 93 | [0, 0] 94 | ] 95 | ], 96 | [ 97 | [ 98 | [0, 0, 0], 99 | [0, 0], 100 | [0, 0, 0] 101 | ] 102 | ], 103 | [ 104 | [ 105 | [0], 106 | [0, 0], 107 | [0] 108 | ] 109 | ] 110 | ]; 111 | } 112 | 113 | /** 114 | * @return mixed[][] 115 | */ 116 | public function invalidPointProvider(): array 117 | { 118 | return [ 119 | [-1, 3, \InvalidArgumentException::class, 'Invalid tile'], 120 | [4, PHP_INT_MAX, \InvalidArgumentException::class, 'Invalid tile'], 121 | [0, 'foo', \TypeError::class, 'must be of type int'], 122 | ['bar', 0, \TypeError::class, 'must be of type int'], 123 | ]; 124 | } 125 | 126 | /** 127 | * @dataProvider validTerrainInformationProvider 128 | * @param mixed[][] $terrainInformation 129 | */ 130 | public function testShouldSetValidTerrainInformation(array $terrainInformation): void 131 | { 132 | $sut = new TerrainCost($terrainInformation); 133 | 134 | $expectedRows = count($terrainInformation); 135 | 136 | // @phpstan-ignore-next-line reset won't return false as the terrain information will be valid 137 | $expectedColumns = count(reset($terrainInformation)); 138 | 139 | $this->assertSame($expectedRows, $sut->getTotalRows()); 140 | $this->assertSame($expectedColumns, $sut->getTotalColumns()); 141 | 142 | $row = 0; 143 | foreach ($terrainInformation as $rowCosts) { 144 | $column = 0; 145 | /** @var numeric $cost */ 146 | foreach ($rowCosts as $cost) { 147 | $expectedCost = (int) $cost; 148 | 149 | $this->assertSame($expectedCost, $sut->getCost($row, $column)); 150 | 151 | $column++; 152 | } 153 | $row++; 154 | } 155 | } 156 | 157 | /** 158 | * @dataProvider emptyTerrainProvider 159 | * @param mixed[] $emptyTerrain 160 | */ 161 | public function testShouldNotSetEmptyTerrain(array $emptyTerrain): void 162 | { 163 | $this->expectException(\InvalidArgumentException::class); 164 | $this->expectExceptionMessage('empty'); 165 | 166 | new TerrainCost($emptyTerrain); 167 | } 168 | 169 | /** 170 | * @dataProvider invalidTerrainCostsProvider 171 | * @param mixed[][] $invalidTerrain 172 | */ 173 | public function testShouldOnlySetIntegerCosts(array $invalidTerrain): void 174 | { 175 | $this->expectException(\InvalidArgumentException::class); 176 | $this->expectExceptionMessage('Invalid terrain cost'); 177 | 178 | new TerrainCost($invalidTerrain); 179 | } 180 | 181 | /** 182 | * @dataProvider nonRectangularTerrainProvider 183 | * @param int[][] $nonRectangularTerrain 184 | */ 185 | public function testShouldOnlySetRectangularTerrains(array $nonRectangularTerrain): void 186 | { 187 | $this->expectException(\InvalidArgumentException::class); 188 | $this->expectExceptionMessage('rectangular'); 189 | 190 | new TerrainCost($nonRectangularTerrain); 191 | } 192 | 193 | /** 194 | * @dataProvider invalidPointProvider 195 | * @param mixed $row 196 | * @param mixed $column 197 | * @param class-string<\Throwable> $expectedException 198 | * @param string $expectedExceptionMessage 199 | */ 200 | public function testShouldThrowExceptionIfTheRequestedTileDoesNotExist( 201 | mixed $row, 202 | mixed $column, 203 | string $expectedException, 204 | string $expectedExceptionMessage, 205 | ): void { 206 | $sut = new TerrainCost([ 207 | [0, 0, 0], 208 | [0, 0, 0], 209 | [0, 0, 0] 210 | ]); 211 | 212 | $this->expectException($expectedException); 213 | $this->expectExceptionMessage($expectedExceptionMessage); 214 | 215 | $sut->getCost($row, $column); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /tests/Node/Collection/NodeHashTableTest.php: -------------------------------------------------------------------------------- 1 | */ 13 | private NodeHashTable $sut; 14 | 15 | protected function setUp(): void 16 | { 17 | $this->sut = new NodeHashTable(); 18 | } 19 | 20 | public function testShouldBeACollectionOfNodes(): void 21 | { 22 | $this->assertInstanceOf(NodeCollectionInterface::class, $this->sut); 23 | } 24 | 25 | public function testShouldBeIterable(): void 26 | { 27 | $this->assertIsIterable($this->sut); 28 | } 29 | 30 | public function testShouldBeInitiallyEmpty(): void 31 | { 32 | $this->assertCount(0, $this->sut); 33 | } 34 | 35 | public function testShouldAddNodes(): void 36 | { 37 | $node1 = $this->createStub(Node::class); 38 | $node1->method('getId') 39 | ->willReturn('Id1'); 40 | 41 | $node2 = $this->createStub(Node::class); 42 | $node2->method('getId') 43 | ->willReturn('Id2'); 44 | 45 | $this->assertCount(0, $this->sut); 46 | 47 | $this->sut->add($node1); 48 | 49 | $this->assertCount(1, $this->sut); 50 | 51 | $this->sut->add($node2); 52 | 53 | $this->assertCount(2, $this->sut); 54 | $this->assertContains($node1, $this->sut); 55 | $this->assertContains($node2, $this->sut); 56 | } 57 | 58 | public function testShouldDetermineIfItIsEmptyOrNot(): void 59 | { 60 | $node = $this->createStub(Node::class); 61 | 62 | $this->assertTrue($this->sut->isEmpty()); 63 | 64 | $this->sut->add($node); 65 | 66 | $this->assertFalse($this->sut->isEmpty()); 67 | } 68 | 69 | public function testShouldOverwriteIdenticalNodes(): void 70 | { 71 | $uniqueId = 'someUniqueId'; 72 | 73 | $node1 = $this->createStub(Node::class); 74 | $node1->method('getId') 75 | ->willReturn($uniqueId); 76 | 77 | $node2 = $this->createStub(Node::class); 78 | $node2->method('getId') 79 | ->willReturn($uniqueId); 80 | 81 | $this->sut->add($node1); 82 | 83 | $this->assertCount(1, $this->sut); 84 | 85 | $this->sut->add($node2); 86 | 87 | $this->assertCount(1, $this->sut); 88 | 89 | foreach ($this->sut as $node) { 90 | $this->assertSame($node2, $node); 91 | } 92 | } 93 | 94 | public function testShouldCheckIfItContainsANode(): void 95 | { 96 | $node = $this->createStub(Node::class); 97 | $node->method('getId') 98 | ->willReturn('someUniqueId'); 99 | 100 | $this->assertNotContains($node, $this->sut); 101 | $this->assertFalse($this->sut->contains($node)); 102 | 103 | $this->sut->add($node); 104 | 105 | $this->assertContains($node, $this->sut); 106 | $this->assertTrue($this->sut->contains($node)); 107 | } 108 | 109 | public function testShouldExtractBestNode(): void 110 | { 111 | $bestNode = $this->createStub(Node::class); 112 | $bestNode->method('getId') 113 | ->willReturn('bestNode'); 114 | $bestNode->method('getF') 115 | ->willReturn(1); 116 | 117 | $mediumNode = $this->createStub(Node::class); 118 | $mediumNode->method('getId') 119 | ->willReturn('mediumNode'); 120 | $mediumNode->method('getF') 121 | ->willReturn(3); 122 | 123 | $worstNode = $this->createStub(Node::class); 124 | $worstNode->method('getId') 125 | ->willReturn('worstNode'); 126 | $worstNode->method('getF') 127 | ->willReturn(10); 128 | 129 | $this->sut->add($mediumNode); 130 | $this->sut->add($bestNode); 131 | $this->sut->add($worstNode); 132 | 133 | $this->assertCount(3, $this->sut); 134 | 135 | $extractedNode = $this->sut->extractBest(); 136 | 137 | $this->assertSame($bestNode, $extractedNode); 138 | $this->assertCount(2, $this->sut); 139 | $this->assertNotContains($extractedNode, $this->sut); 140 | } 141 | 142 | public function testShouldRemoveNode(): void 143 | { 144 | $nodeToBeRemoved = $this->createStub(Node::class); 145 | $nodeToBeRemoved->method('getId') 146 | ->willReturn('nodeToBeRemoved'); 147 | 148 | $nodeToBeKept = $this->createStub(Node::class); 149 | $nodeToBeKept->method('getId') 150 | ->willReturn('nodeToBeKept'); 151 | 152 | $this->sut->add($nodeToBeRemoved); 153 | $this->sut->add($nodeToBeKept); 154 | 155 | $this->assertCount(2, $this->sut); 156 | 157 | $this->sut->remove($nodeToBeRemoved); 158 | 159 | $this->assertCount(1, $this->sut); 160 | $this->assertNotContains($nodeToBeRemoved, $this->sut); 161 | $this->assertContains($nodeToBeKept, $this->sut); 162 | } 163 | 164 | public function testShouldGetNodeById(): void 165 | { 166 | $nodeId = 'someUniqueId'; 167 | 168 | $node = $this->createStub(Node::class); 169 | $node->method('getId') 170 | ->willReturn($nodeId); 171 | 172 | $this->sut->add($node); 173 | 174 | $this->assertSame($node, $this->sut->get($nodeId)); 175 | } 176 | 177 | public function testShouldGetNullIfNodeNotFound(): void 178 | { 179 | $nonExistentNodeId = 'foo'; 180 | 181 | $this->assertNull($this->sut->get($nonExistentNodeId)); 182 | } 183 | 184 | public function testShouldEmptyTheList(): void 185 | { 186 | $node = $this->createStub(Node::class); 187 | $node->method('getId') 188 | ->willReturn('someUniqueId'); 189 | 190 | $this->sut->add($node); 191 | 192 | $this->assertCount(1, $this->sut); 193 | 194 | $this->sut->clear(); 195 | 196 | $this->assertCount(0, $this->sut); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /tests/Node/NodeTest.php: -------------------------------------------------------------------------------- 1 | */ 12 | private Node $sut; 13 | private string $mockState = 'foobar'; 14 | 15 | /** 16 | * @return mixed[][] 17 | */ 18 | public function validNumberProvider(): array 19 | { 20 | return [ 21 | [1], 22 | [1.5], 23 | ['1.5'], 24 | ['200'], 25 | [0], 26 | [PHP_INT_MAX] 27 | ]; 28 | } 29 | 30 | /** 31 | * @return mixed[][] 32 | */ 33 | public function invalidNumberProvider(): array 34 | { 35 | return [ 36 | ['a'], 37 | [[]], 38 | [null], 39 | [''], 40 | [' '] 41 | ]; 42 | } 43 | 44 | protected function setUp(): void 45 | { 46 | $this->sut = new Node($this->mockState); 47 | } 48 | 49 | public function testShouldHaveNoParentInitially(): void 50 | { 51 | $this->assertNull($this->sut->getParent()); 52 | } 53 | 54 | public function testShouldSetParent(): void 55 | { 56 | /** @var Node */ 57 | $parent = $this->createStub(Node::class); 58 | 59 | $this->assertNull($this->sut->getParent()); 60 | 61 | $this->sut->setParent($parent); 62 | 63 | $this->assertSame($parent, $this->sut->getParent()); 64 | } 65 | 66 | public function testShouldSetState(): void 67 | { 68 | $this->assertSame($this->mockState, $this->sut->getState()); 69 | } 70 | 71 | public function testShouldSetItsIdToTheOneProvidedByTheUser(): void 72 | { 73 | $uniqueNodeId = 'some-unique-id'; 74 | 75 | $mockStateWithId = $this->createMock(NodeIdentifierInterface::class); 76 | $mockStateWithId->expects($this->once()) 77 | ->method('getUniqueNodeId') 78 | ->willReturn($uniqueNodeId); 79 | 80 | /** @var Node */ 81 | $sut = new Node($mockStateWithId); 82 | 83 | $this->assertSame($uniqueNodeId, $sut->getId()); 84 | } 85 | 86 | public function testShouldSetItsIdToTheSerialisedStateIfTheUserDoesNotProvideAnId(): void 87 | { 88 | $expectedId = serialize($this->mockState); 89 | 90 | $this->assertSame($expectedId, $this->sut->getId()); 91 | } 92 | 93 | /** 94 | * @dataProvider validNumberProvider 95 | */ 96 | public function testShouldSetValidG(mixed $validScore): void 97 | { 98 | $this->sut->setG($validScore); 99 | 100 | $actualScore = $this->sut->getG(); 101 | 102 | $this->assertIsNumeric($actualScore); 103 | $this->assertEquals($validScore, $actualScore); 104 | } 105 | 106 | /** 107 | * @dataProvider invalidNumberProvider 108 | */ 109 | public function testShouldNotSetInvalidG(mixed $invalidScore): void 110 | { 111 | $this->expectException(\TypeError::class); 112 | 113 | $this->sut->setG($invalidScore); 114 | } 115 | 116 | /** 117 | * @dataProvider validNumberProvider 118 | */ 119 | public function testShouldSetValidH(mixed $validScore): void 120 | { 121 | $this->sut->setH($validScore); 122 | 123 | $actualScore = $this->sut->getH(); 124 | 125 | $this->assertIsNumeric($actualScore); 126 | $this->assertEquals($validScore, $actualScore); 127 | } 128 | 129 | /** 130 | * @dataProvider invalidNumberProvider 131 | */ 132 | public function testShouldNotSetInvalidH(mixed $invalidScore): void 133 | { 134 | $this->expectException(\TypeError::class); 135 | 136 | $this->sut->setH($invalidScore); 137 | } 138 | 139 | public function testShouldGetF(): void 140 | { 141 | $g = 3; 142 | $h = 5; 143 | $expectedF = $g + $h; 144 | 145 | $this->sut->setG($g); 146 | $this->sut->setH($h); 147 | 148 | $this->assertSame($expectedF, $this->sut->getF()); 149 | } 150 | } 151 | --------------------------------------------------------------------------------