From cbe7d14b926ad4b9ab025014fcec92500d651d58 Mon Sep 17 00:00:00 2001 From: homersimpsons Date: Mon, 1 Apr 2024 22:57:12 +0000 Subject: [PATCH] :construction: test generator --- .devcontainer/devcontainer.json | 9 + exercises/practice/list-ops/ListOpsTest.php | 20 +- .../practice/list-ops/ListOpsTest.php.twig | 226 ++++++++++++++++++ test-generator/.gitignore | 1 + test-generator/.phpcs-cache | 1 + test-generator/.phpunit.cache/test-results | 1 + test-generator/README.md | 20 ++ test-generator/composer.json | 41 ++++ test-generator/main.php | 8 + test-generator/phpcs.xml.dist | 37 +++ test-generator/phpstan.neon | 2 + test-generator/phpunit.xml.dist | 24 ++ test-generator/src/Application.php | 126 ++++++++++ test-generator/tests/ApplicationTest.php | 29 +++ 14 files changed, 535 insertions(+), 10 deletions(-) create mode 100644 .devcontainer/devcontainer.json create mode 100644 exercises/practice/list-ops/ListOpsTest.php.twig create mode 100644 test-generator/.gitignore create mode 100644 test-generator/.phpcs-cache create mode 100644 test-generator/.phpunit.cache/test-results create mode 100644 test-generator/README.md create mode 100644 test-generator/composer.json create mode 100644 test-generator/main.php create mode 100644 test-generator/phpcs.xml.dist create mode 100644 test-generator/phpstan.neon create mode 100644 test-generator/phpunit.xml.dist create mode 100644 test-generator/src/Application.php create mode 100644 test-generator/tests/ApplicationTest.php diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..a103e397 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,9 @@ +{ + "image": "mcr.microsoft.com/devcontainers/universal:2", + "features": { + "ghcr.io/devcontainers/features/php:1": { + "version": "8.3", + "installComposer": true + } + } + } \ No newline at end of file diff --git a/exercises/practice/list-ops/ListOpsTest.php b/exercises/practice/list-ops/ListOpsTest.php index 4d0f35ea..1cee9569 100644 --- a/exercises/practice/list-ops/ListOpsTest.php +++ b/exercises/practice/list-ops/ListOpsTest.php @@ -36,7 +36,7 @@ public static function setUpBeforeClass(): void /** * @testdox append entries to a list and return the new list -> empty lists */ - public function testAppendEmptyLists() + public function testAppendEntriesToAListAndReturnTheNewListEmptyLists() { $listOps = new ListOps(); $this->assertEquals([], $listOps->append([], [])); @@ -45,25 +45,25 @@ public function testAppendEmptyLists() /** * @testdox append entries to a list and return the new list -> list to empty list */ - public function testAppendNonEmptyListToEmptyList() + public function testAppendEntriesToAListAndReturnTheNewListListToEmptyList() { $listOps = new ListOps(); - $this->assertEquals([1, 2, 3, 4], $listOps->append([1, 2, 3, 4], [])); + $this->assertEquals([1, 2, 3, 4], $listOps->append([], [1, 2, 3, 4])); } /** * @testdox append entries to a list and return the new list -> empty list to list */ - public function testAppendEmptyListToNonEmptyList() + public function testAppendEntriesToAListAndReturnTheNewListEmptyListToList() { $listOps = new ListOps(); - $this->assertEquals([1, 2, 3, 4], $listOps->append([], [1, 2, 3, 4])); + $this->assertEquals([1, 2, 3, 4], $listOps->append([1, 2, 3, 4], [])); } /** * @testdox append entries to a list and return the new list -> non-empty lists */ - public function testAppendNonEmptyLists() + public function testAppendEntriesToAListAndReturnTheNewListNonEmptyLists() { $listOps = new ListOps(); $this->assertEquals([1, 2, 2, 3, 4, 5], $listOps->append([1, 2], [2, 3, 4, 5])); @@ -72,16 +72,16 @@ public function testAppendNonEmptyLists() /** * @testdox concatenate a list of lists -> empty list */ - public function testConcatEmptyLists() + public function testConcatenateAListOfListsEmptyList() { $listOps = new ListOps(); - $this->assertEquals([], $listOps->concat([], [])); + $this->assertEquals([], $listOps->concat()); } /** * @testdox concatenate a list of lists -> list of lists */ - public function testConcatLists() + public function testConcatenateAListOfListsListOfLists() { $listOps = new ListOps(); $this->assertEquals([1, 2, 3, 4, 5, 6], $listOps->concat([1, 2], [3], [], [4, 5, 6])); @@ -90,7 +90,7 @@ public function testConcatLists() /** * @testdox concatenate a list of lists -> list of nested lists */ - public function testConcatNestedLists() + public function testConcatenateAListOfListsListOfNestedLists() { $listOps = new ListOps(); $this->assertEquals([[1], [2], [3], [], [4, 5, 6]], $listOps->concat([[1], [2]], [[3]], [[]], [[4, 5, 6]])); diff --git a/exercises/practice/list-ops/ListOpsTest.php.twig b/exercises/practice/list-ops/ListOpsTest.php.twig new file mode 100644 index 00000000..2199556e --- /dev/null +++ b/exercises/practice/list-ops/ListOpsTest.php.twig @@ -0,0 +1,226 @@ +. + * + * To disable strict typing, comment out the directive below. + */ + +declare(strict_types=1); + +use PHPUnit\Framework\ExpectationFailedException; + +class ListOpsTest extends PHPUnit\Framework\TestCase +{ + public static function setUpBeforeClass(): void + { + require_once 'ListOps.php'; + } + + {% set case0 = cases[0] -%} + {% for case in case0.cases -%} + /** + * @testdox {{ case0.description }} -> {{ case.description }} + */ + public function {{ testfn(case0.description ~ ' ' ~ case.description) }}() + { + $listOps = new ListOps(); + $this->assertEquals({{ export(case.expected) }}, $listOps->{{ case.property }}({{ export(case.input.list1) }}, {{ export(case.input.list2) }})); + } + + {% endfor -%} + + {% set case1 = cases[1] -%} + {% for case in case1.cases -%} + /** + * @testdox {{ case1.description }} -> {{ case.description }} + */ + public function {{ testfn(case1.description ~ ' ' ~ case.description) }}() + { + $listOps = new ListOps(); + $this->assertEquals({{ export(case.expected) }}, $listOps->{{ case.property }}({{ case.input.lists | map(l => export(l)) | join(', ') }})); + } + + {% endfor -%} + + /** + * @testdox filter list returning only values that satisfy the filter function -> empty list + */ + public function testFilterEmptyList() + { + $listOps = new ListOps(); + $this->assertEquals( + [], + $listOps->filter(static fn ($el) => $el % 2 === 1, []) + ); + } + + /** + * @testdox filter list returning only values that satisfy the filter function -> non empty list + */ + public function testFilterNonEmptyList() + { + $listOps = new ListOps(); + $this->assertEquals( + [1, 3, 5], + $listOps->filter(static fn ($el) => $el % 2 === 1, [1, 2, 3, 5]) + ); + } + + /** + * @testdox returns the length of a list -> empty list + */ + public function testLengthEmptyList() + { + $listOps = new ListOps(); + $this->assertEquals(0, $listOps->length([])); + } + + /** + * @testdox returns the length of a list -> non-empty list + */ + public function testLengthNonEmptyList() + { + $listOps = new ListOps(); + $this->assertEquals(4, $listOps->length([1, 2, 3, 4])); + } + + /** + * @testdox returns a list of elements whose values equal the list value transformed by the mapping function -> empty list + */ + public function testMapEmptyList() + { + $listOps = new ListOps(); + $this->assertEquals( + [], + $listOps->map(static fn ($el) => $el + 1, []) + ); + } + + /** + * @testdox returns a list of elements whose values equal the list value transformed by the mapping function -> non-empty list + */ + public function testMapNonEmptyList() + { + $listOps = new ListOps(); + $this->assertEquals( + [2, 4, 6, 8], + $listOps->map(static fn ($el) => $el + 1, [1, 3, 5, 7]) + ); + } + + /** + * @testdox folds (reduces) the given list from the left with a function -> empty list + */ + public function testFoldlEmptyList() + { + $listOps = new ListOps(); + $this->assertEquals( + 2, + $listOps->foldl(static fn ($acc, $el) => $el * $acc, [], 2) + ); + } + + /** + * @testdox folds (reduces) the given list from the left with a function -> direction independent function applied to non-empty list + */ + public function testFoldlDirectionIndependentNonEmptyList() + { + $listOps = new ListOps(); + $this->assertEquals( + 15, + $listOps->foldl(static fn ($acc, $el) => $acc + $el, [1, 2, 3, 4], 5) + ); + } + + /** + * @testdox folds (reduces) the given list from the left with a function -> direction dependent function applied to non-empty list + */ + public function testFoldlDirectionDependentNonEmptyList() + { + $listOps = new ListOps(); + $this->assertEquals( + 64, + $listOps->foldl(static fn ($acc, $el) => $el / $acc, [1, 2, 3, 4], 24) + ); + } + + /** + * @testdox folds (reduces) the given list from the right with a function -> empty list + */ + public function testFoldrEmptyList() + { + $listOps = new ListOps(); + $this->assertEquals( + 2, + $listOps->foldr(static fn ($acc, $el) => $el * $acc, [], 2) + ); + } + + /** + * @testdox folds (reduces) the given list from the right with a function -> direction independent function applied to non-empty list + */ + public function testFoldrDirectionIndependentNonEmptyList() + { + $listOps = new ListOps(); + $this->assertEquals( + 15, + $listOps->foldr(static fn ($acc, $el) => $acc + $el, [1, 2, 3, 4], 5) + ); + } + + /** + * @testdox folds (reduces) the given list from the right with a function -> direction dependent function applied to non-empty list + */ + public function testFoldrDirectionDependentNonEmptyList() + { + $listOps = new ListOps(); + $this->assertEquals( + 9, + $listOps->foldr(static fn ($acc, $el) => $el / $acc, [1, 2, 3, 4], 24) + ); + } + + /** + * @testdox reverse the elements of a list -> empty list + */ + public function testReverseEmptyList() + { + $listOps = new ListOps(); + $this->assertEquals([], $listOps->reverse([])); + } + + /** + * @testdox reverse the elements of a list -> non-empty list + */ + public function testReverseNonEmptyList() + { + $listOps = new ListOps(); + $this->assertEquals([7, 5, 3, 1], $listOps->reverse([1, 3, 5, 7])); + } + + /** + * @testdox reverse the elements of a list -> list of lists is not flattened + */ + public function testReverseNonEmptyListIsNotFlattened() + { + $listOps = new ListOps(); + $this->assertEquals([[4, 5, 6], [], [3], [1, 2]], $listOps->reverse([[1, 2], [3], [], [4, 5, 6]])); + } +} diff --git a/test-generator/.gitignore b/test-generator/.gitignore new file mode 100644 index 00000000..48b8bf90 --- /dev/null +++ b/test-generator/.gitignore @@ -0,0 +1 @@ +vendor/ diff --git a/test-generator/.phpcs-cache b/test-generator/.phpcs-cache new file mode 100644 index 00000000..5d010624 --- /dev/null +++ b/test-generator/.phpcs-cache @@ -0,0 +1 @@ +{"config":{"phpVersion":80304,"phpExtensions":"bfa29435b23fb16f2df656fc01091a26","tabWidth":4,"encoding":"utf-8","recordErrors":true,"annotations":true,"configData":{"installed_paths":"vendor\/doctrine\/coding-standard\/lib,vendor\/slevomat\/coding-standard"},"codeHash":"07f2495a403dcace37e6b2d597560f22","rulesetHash":"6437dc4eda23e7c26f5f667da4f4ba90"},"\/workspaces\/exercism-php\/test-generator\/src\/Application.php":{"hash":"67b9e6e99a15e5a8f6d13d75486d6ba433206","errors":{"38":{"12":[{"message":"Missing doc comment for function __construct()","source":"Squiz.Commenting.FunctionComment.Missing","listener":"PHP_CodeSniffer\\Standards\\Squiz\\Sniffs\\Commenting\\FunctionCommentSniff","severity":0,"fixable":false}]},"43":{"15":[{"message":"Missing doc comment for function configure()","source":"Squiz.Commenting.FunctionComment.Missing","listener":"PHP_CodeSniffer\\Standards\\Squiz\\Sniffs\\Commenting\\FunctionCommentSniff","severity":0,"fixable":false}]},"54":{"15":[{"message":"Missing doc comment for function execute()","source":"Squiz.Commenting.FunctionComment.Missing","listener":"PHP_CodeSniffer\\Standards\\Squiz\\Sniffs\\Commenting\\FunctionCommentSniff","severity":0,"fixable":false}]},"56":{"23":[{"message":"Equals sign not aligned with surrounding assignments; expected 2 spaces but found 1 space","source":"Generic.Formatting.MultipleStatementAlignment.NotSame","listener":"PHP_CodeSniffer\\Standards\\Generic\\Sniffs\\Formatting\\MultipleStatementAlignmentSniff","severity":0,"fixable":true}]},"68":{"9":[{"message":"Expected 1 line before \"if\", found 0.","source":"SlevomatCodingStandard.ControlStructures.BlockControlStructureSpacing.IncorrectLinesCountBeforeControlStructure","listener":"SlevomatCodingStandard\\Sniffs\\ControlStructures\\BlockControlStructureSpacingSniff","severity":0,"fixable":true}]},"74":{"26":[{"message":"Equals sign not aligned with surrounding assignments; expected 4 spaces but found 1 space","source":"Generic.Formatting.MultipleStatementAlignment.NotSame","listener":"PHP_CodeSniffer\\Standards\\Generic\\Sniffs\\Formatting\\MultipleStatementAlignmentSniff","severity":0,"fixable":true}]},"82":{"52":[{"message":"Missing @return tag in function comment","source":"Squiz.Commenting.FunctionComment.MissingReturn","listener":"PHP_CodeSniffer\\Standards\\Squiz\\Sniffs\\Commenting\\FunctionCommentSniff","severity":0,"fixable":false}],"9":[{"message":"Doc comment for parameter $canonicalData does not match actual variable name $exerciseDir","source":"Squiz.Commenting.FunctionComment.ParamNameNoMatch","listener":"PHP_CodeSniffer\\Standards\\Squiz\\Sniffs\\Commenting\\FunctionCommentSniff","severity":0,"fixable":false}],"5":[{"message":"Doc comment for parameter \"$exerciseDir\" missing","source":"Squiz.Commenting.FunctionComment.MissingParamTag","listener":"PHP_CodeSniffer\\Standards\\Squiz\\Sniffs\\Commenting\\FunctionCommentSniff","severity":0,"fixable":false},{"message":"Doc comment for parameter \"$check\" missing","source":"Squiz.Commenting.FunctionComment.MissingParamTag","listener":"PHP_CodeSniffer\\Standards\\Squiz\\Sniffs\\Commenting\\FunctionCommentSniff","severity":0,"fixable":false},{"message":"Doc comment for parameter \"$logger\" missing","source":"Squiz.Commenting.FunctionComment.MissingParamTag","listener":"PHP_CodeSniffer\\Standards\\Squiz\\Sniffs\\Commenting\\FunctionCommentSniff","severity":0,"fixable":false}]},"83":{"12":[{"message":"Expected type hint \"array\"; found \"Filesystem\" for $canonicalData","source":"Squiz.Commenting.FunctionComment.IncorrectTypeHint","listener":"PHP_CodeSniffer\\Standards\\Squiz\\Sniffs\\Commenting\\FunctionCommentSniff","severity":0,"fixable":false}]},"87":{"17":[{"message":"Equals sign not aligned with surrounding assignments; expected 5 spaces but found 1 space","source":"Generic.Formatting.MultipleStatementAlignment.NotSame","listener":"PHP_CodeSniffer\\Standards\\Generic\\Sniffs\\Formatting\\MultipleStatementAlignmentSniff","severity":0,"fixable":true}]},"102":{"21":[{"message":"Equals sign not aligned with surrounding assignments; expected 6 spaces but found 1 space","source":"Generic.Formatting.MultipleStatementAlignment.NotSame","listener":"PHP_CodeSniffer\\Standards\\Generic\\Sniffs\\Formatting\\MultipleStatementAlignmentSniff","severity":0,"fixable":true}]},"106":{"9":[{"message":"Expected 1 line before \"foreach\", found 0.","source":"SlevomatCodingStandard.ControlStructures.BlockControlStructureSpacing.IncorrectLinesCountBeforeControlStructure","listener":"SlevomatCodingStandard\\Sniffs\\ControlStructures\\BlockControlStructureSpacingSniff","severity":0,"fixable":true}]},"111":{"23":[{"message":"Equals sign not aligned with surrounding assignments; expected 5 spaces but found 1 space","source":"Generic.Formatting.MultipleStatementAlignment.NotSame","listener":"PHP_CodeSniffer\\Standards\\Generic\\Sniffs\\Formatting\\MultipleStatementAlignmentSniff","severity":0,"fixable":true}]}},"warnings":{"50":{"166":[{"message":"Line exceeds 120 characters; contains 166 characters","source":"Generic.Files.LineLength.TooLong","listener":"PHP_CodeSniffer\\Standards\\Generic\\Sniffs\\Files\\LineLengthSniff","severity":5,"fixable":false}]},"51":{"133":[{"message":"Line exceeds 120 characters; contains 133 characters","source":"Generic.Files.LineLength.TooLong","listener":"PHP_CodeSniffer\\Standards\\Generic\\Sniffs\\Files\\LineLengthSniff","severity":5,"fixable":false}]},"104":{"150":[{"message":"Line exceeds 120 characters; contains 150 characters","source":"Generic.Files.LineLength.TooLong","listener":"PHP_CodeSniffer\\Standards\\Generic\\Sniffs\\Files\\LineLengthSniff","severity":5,"fixable":false}]},"105":{"180":[{"message":"Line exceeds 120 characters; contains 180 characters","source":"Generic.Files.LineLength.TooLong","listener":"PHP_CodeSniffer\\Standards\\Generic\\Sniffs\\Files\\LineLengthSniff","severity":5,"fixable":false}]}},"metrics":{"Declarations and side effects mixed":{"values":{"no":1}},"PHP short open tag used":{"values":{"no":1}},"EOL char":{"values":{"\\n":1}},"Number of newlines at EOF":{"values":{"1":1}},"PHP closing tag at end of PHP-only file":{"values":{"no":1}},"Line length":{"values":{"80 or less":87,"81-120":14,"151 or more":2,"121-150":2}},"Line indent":{"values":{"spaces":73}},"PHP keyword case":{"values":{"lower":71}},"Multiple statements on same line":{"values":{"no":68}},"One class per file":{"values":{"yes":1}},"Class defined in namespace":{"values":{"yes":1}},"PascalCase class name":{"values":{"yes":1}},"Class opening brace placement":{"values":{"new line":1}},"Function opening brace placement":{"values":{"new line":4}},"Function has doc comment":{"values":{"no":3,"yes":1}},"Function spacing after":{"values":{"1":3}},"Function spacing before first":{"values":[1]},"Spacing before object operator":{"values":[29]},"Spacing after object operator":{"values":[29]},"CamelCase method name":{"values":{"yes":3}},"PHP type case":{"values":{"lower":7}},"Inline comment style":{"values":{"\/\/ ...":7}},"PHP constant case":{"values":{"lower":6}},"Adjacent assignments aligned":{"values":{"no":5}},"Space before operator":{"values":{"1":22}},"Space after operator":{"values":{"1":22}},"Spacing before string concat":{"values":{"1":5}},"Spacing after string concat":{"values":{"1":5}},"Spaces after control structure open parenthesis":{"values":[6]},"Spaces before control structure close parenthesis":{"values":[6]},"Blank lines at start of control structure":{"values":[7]},"Blank lines at end of control structure":{"values":[7]},"Control structure defined inline":{"values":{"no":7}},"Function spacing after last":{"values":[1]},"Short array syntax used":{"values":{"yes":1}},"Array end comma":{"values":{"no":1}}},"errorCount":16,"warningCount":4,"fixableCount":7,"numTokens":1132},"\/workspaces\/exercism-php\/test-generator\/tests\/ApplicationTest.php":{"hash":"573257293fd57902515d6602311ea2b633206","errors":{"15":{"12":[{"message":"Missing doc comment for function testGenerate()","source":"Squiz.Commenting.FunctionComment.Missing","listener":"PHP_CodeSniffer\\Standards\\Squiz\\Sniffs\\Commenting\\FunctionCommentSniff","severity":0,"fixable":false}]},"17":{"19":[{"message":"Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space","source":"Generic.Formatting.MultipleStatementAlignment.NotSame","listener":"PHP_CodeSniffer\\Standards\\Generic\\Sniffs\\Formatting\\MultipleStatementAlignmentSniff","severity":0,"fixable":true}]},"21":{"26":[{"message":"Array with multiple values cannot be declared on a single line","source":"Squiz.Arrays.ArrayDeclaration.SingleLineNotAllowed","listener":"PHP_CodeSniffer\\Standards\\Squiz\\Sniffs\\Arrays\\ArrayDeclarationSniff","severity":0,"fixable":true}],"34":[{"message":"Array with multiple values cannot be declared on a single line","source":"Squiz.Arrays.ArrayDeclaration.SingleLineNotAllowed","listener":"PHP_CodeSniffer\\Standards\\Squiz\\Sniffs\\Arrays\\ArrayDeclarationSniff","severity":0,"fixable":true}]},"24":{"18":[{"message":"Equals sign not aligned with surrounding assignments; expected 5 spaces but found 1 space","source":"Generic.Formatting.MultipleStatementAlignment.NotSame","listener":"PHP_CodeSniffer\\Standards\\Generic\\Sniffs\\Formatting\\MultipleStatementAlignmentSniff","severity":0,"fixable":true}]}},"warnings":[],"metrics":{"Declarations and side effects mixed":{"values":{"no":1}},"PHP short open tag used":{"values":{"no":1}},"EOL char":{"values":{"\\n":1}},"Number of newlines at EOF":{"values":{"1":1}},"PHP closing tag at end of PHP-only file":{"values":{"no":1}},"Line length":{"values":{"80 or less":19,"81-120":4}},"Line indent":{"values":{"spaces":12}},"PHP keyword case":{"values":{"lower":15}},"Multiple statements on same line":{"values":{"no":16}},"One class per file":{"values":{"yes":1}},"Class defined in namespace":{"values":{"yes":1}},"PascalCase class name":{"values":{"yes":1}},"Class opening brace placement":{"values":{"new line":1}},"CamelCase method name":{"values":{"yes":1}},"PHP type case":{"values":{"lower":1}},"Function opening brace placement":{"values":{"new line":1}},"Function has doc comment":{"values":{"no":1}},"Function spacing after last":{"values":[1]},"Function spacing before first":{"values":[1]},"Adjacent assignments aligned":{"values":{"no":2}},"Space before operator":{"values":{"1":7}},"Space after operator":{"values":{"1":7}},"Spacing before object operator":{"values":[6]},"Spacing after object operator":{"values":[6]},"Short array syntax used":{"values":{"yes":2}},"Array end comma":{"values":{"no":2}},"PHP constant case":{"values":{"lower":1}}},"errorCount":5,"warningCount":0,"fixableCount":4,"numTokens":236}} \ No newline at end of file diff --git a/test-generator/.phpunit.cache/test-results b/test-generator/.phpunit.cache/test-results new file mode 100644 index 00000000..40e0a006 --- /dev/null +++ b/test-generator/.phpunit.cache/test-results @@ -0,0 +1 @@ +{"version":1,"defects":{"App\\Tests\\ApplicationTest::testGenerate":7},"times":{"App\\Tests\\ApplicationTest::testGenerate":0.048}} \ No newline at end of file diff --git a/test-generator/README.md b/test-generator/README.md new file mode 100644 index 00000000..d48a4f59 --- /dev/null +++ b/test-generator/README.md @@ -0,0 +1,20 @@ +TODO: +- [ ] Readme + - [ ] Requirements (php 8.3) + - [ ] Usage `php test-generator/main.php exercises/practice/list-ops/ /home/codespace/.cache/exercism/configlet/problem-specifications/exercises/list-ops/canonical-data.json -vv` + - [ ] https://twig.symfony.com/ + - [ ] custom functions `export` / `testf` +- [ ] CI (generator) + - [ ] `phpstan` + - [ ] `phpcs` + - [ ] `phpunit` +- [ ] CI (exercises): iterate over each exercise and run the generator in check mode +- [ ] Write tests +- [ ] Path to convert existing exercises to the test-generator +- [ ] `@TODO` +- [ ] Upgrade https://github.com/brick/varexporter +- [ ] Going further + - [ ] Skip re-implements + - [ ] Read .meta/tests.toml to skip cases by uuid + - [ ] Default templates to include (strict_types header, require_once based on config, testfn header [testdox, uuid, task_id]) + - [ ] devcontainer for easy contribution in github codespace directly \ No newline at end of file diff --git a/test-generator/composer.json b/test-generator/composer.json new file mode 100644 index 00000000..b6a4ecb5 --- /dev/null +++ b/test-generator/composer.json @@ -0,0 +1,41 @@ +{ + "name": "exercism/test-generator", + "type": "project", + "require": { + "brick/varexporter": "^0.4.0", + "league/flysystem": "^3.26", + "league/flysystem-memory": "^3.25", + "psr/log": "^3.0", + "symfony/console": "^6.0", + "twig/twig": "^3.8" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.0", + "squizlabs/php_codesniffer": "^3.9" + }, + "license": "MIT", + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\Tests\\": "tests" + } + }, + "scripts": { + "phpstan": "phpstan analyse src tests --configuration phpstan.neon --memory-limit=2G", + "test": "phpunit", + "lint": "phpcs", + "lint:fix": "phpcbf" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + }, + "sort-packages": true + } +} diff --git a/test-generator/main.php b/test-generator/main.php new file mode 100644 index 00000000..a9a519e6 --- /dev/null +++ b/test-generator/main.php @@ -0,0 +1,8 @@ +run(); diff --git a/test-generator/phpcs.xml.dist b/test-generator/phpcs.xml.dist new file mode 100644 index 00000000..8a22e1cb --- /dev/null +++ b/test-generator/phpcs.xml.dist @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + src/ + tests/ + \ No newline at end of file diff --git a/test-generator/phpstan.neon b/test-generator/phpstan.neon new file mode 100644 index 00000000..22254bcd --- /dev/null +++ b/test-generator/phpstan.neon @@ -0,0 +1,2 @@ +parameters: + level: max diff --git a/test-generator/phpunit.xml.dist b/test-generator/phpunit.xml.dist new file mode 100644 index 00000000..1444de8b --- /dev/null +++ b/test-generator/phpunit.xml.dist @@ -0,0 +1,24 @@ + + + + + ./tests + + + + + src/ + + + + + + + + + + \ No newline at end of file diff --git a/test-generator/src/Application.php b/test-generator/src/Application.php new file mode 100644 index 00000000..78500259 --- /dev/null +++ b/test-generator/src/Application.php @@ -0,0 +1,126 @@ +setVersion('1.0.0'); + // @TODO + $this->addArgument('exercise-path', InputArgument::REQUIRED, 'Path of the exercise.'); + $this->addArgument('canonical-data', InputArgument::REQUIRED, 'Path of the canonical data for the exercise. (Use `bin/configlet -verbosity info --offline`)'); + $this->addOption('check', null, InputOption::VALUE_NONE, 'Checks whether the existing files are the same as generated one.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $exercisePath = $input->getArgument('exercise-path'); + $canonicalPath = $input->getArgument('canonical-data'); + $exerciseCheck = $input->getOption('check'); + assert(is_string($exercisePath), 'exercise-path must be a string'); + assert(is_string($canonicalPath), 'canonical-data must be a string'); + assert(is_bool($exerciseCheck), 'check must be a bool'); + + $logger = new ConsoleLogger($output); + $logger->info('Exercise path: ' . $exercisePath); + $logger->info('canonical-data path: ' . $canonicalPath); + + $canonicalDataJson = file_get_contents($canonicalPath); + if ($canonicalDataJson === false) { + throw new RuntimeException('Faield to fetch canonical-data.json, check you `canonical-data` argument.'); + } + + $canonicalData = json_decode($canonicalDataJson, true, flags: JSON_THROW_ON_ERROR); + assert(is_array($canonicalData), 'json_decode(..., true) should return an array'); + $exerciseAdapter = new LocalFilesystemAdapter($exercisePath); + $exerciseFilesystem = new Filesystem($exerciseAdapter); + + $success = $this->generate($exerciseFilesystem, $exerciseCheck, $canonicalData, $logger); + + return $success ? self::SUCCESS : self::FAILURE; + } + + /** @param array $canonicalData */ + public function generate(Filesystem $exerciseDir, bool $check, array $canonicalData, LoggerInterface $logger): bool + { + // 1. Read config.json + $configJson = $exerciseDir->read('/.meta/config.json'); + $config = json_decode($configJson, true, flags: JSON_THROW_ON_ERROR); + assert(is_array($config), 'json_decode(..., true) should return an array'); + + if (! isset($config['files']['test']) || ! is_array($config['files']['test'])) { + throw new RuntimeException('.meta/config.json: missing or invalid `files.test` key'); + } + + $testsPaths = $config['files']['test']; + $logger->info('.meta/config.json: tests files: ' . implode(', ', $testsPaths)); + + if (empty($testsPaths)) { + $logger->warning('.meta/config.json: `files.test` key is empty'); + } + + // 2. foreach tests files, check if there is a twig file + $twigLoader = new ArrayLoader(); + $twigEnvironment = new Environment($twigLoader, ['autoescape' => false]); + $twigEnvironment->addFunction(new TwigFunction('export', static fn (mixed $value) => VarExporter::export($value, VarExporter::INLINE_ARRAY))); + $twigEnvironment->addFunction(new TwigFunction('testfn', static fn (string $label) => 'test' . str_replace(' ', '', ucwords(preg_replace('/[^a-zA-Z0-9]/', ' ', $label))))); + foreach ($testsPaths as $testPath) { + // 3. generate the file + $twigFilename = $testPath . '.twig'; + // @TODO warning or error if it does not exist + $testTemplate = $exerciseDir->read($twigFilename); + $rendered = $twigEnvironment->createTemplate($testTemplate, $twigFilename)->render($canonicalData); + + if ($check) { + // 4. Compare it if check mode + if ($exerciseDir->read($testPath) !== $rendered) { + // return false; + throw new Exception('Differences between generated and existing file'); + } + } else { + $exerciseDir->write($testPath, $rendered); + } + } + + return true; + } +} diff --git a/test-generator/tests/ApplicationTest.php b/test-generator/tests/ApplicationTest.php new file mode 100644 index 00000000..2ab622eb --- /dev/null +++ b/test-generator/tests/ApplicationTest.php @@ -0,0 +1,29 @@ +write('.meta/config.json', '{"files":{"test":["test.php"]}}'); + $exerciseFs->write('test.php.twig', ' [1, 2], 'l' => 'this-Is_a test fn']; + + $application = new Application(); + $success = $application->generate($exerciseFs, false, $canonicalData, new NullLogger()); + + $this->assertTrue($success); + $this->assertSame('read('/test.php')); + } +}