Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🚧 test generator #674

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"image": "mcr.microsoft.com/devcontainers/universal:2",
"features": {
"ghcr.io/devcontainers/features/php:1": {
"version": "8.3",
"installComposer": true
}
}
}
242 changes: 161 additions & 81 deletions exercises/practice/list-ops/ListOpsTest.php
Original file line number Diff line number Diff line change
@@ -33,231 +33,311 @@ public static function setUpBeforeClass(): void
require_once 'ListOps.php';
}


/**
* @testdox append entries to a list and return the new list -> empty lists
*/
public function testAppendEmptyLists()
public function testAppendEntriesToAListAndReturnTheNewListWithEmptyLists()
{
$listOps = new ListOps();
$this->assertEquals([], $listOps->append([], []));
$list1 = [];
$list2 = [];

$result = $listOps->append($list1, $list2);

$this->assertEquals([], $result);
}

/**
* @testdox append entries to a list and return the new list -> list to empty list
*/
public function testAppendNonEmptyListToEmptyList()
public function testAppendEntriesToAListAndReturnTheNewListWithListToEmptyList()
{
$listOps = new ListOps();
$this->assertEquals([1, 2, 3, 4], $listOps->append([1, 2, 3, 4], []));
$list1 = [];
$list2 = [1, 2, 3, 4];

$result = $listOps->append($list1, $list2);

$this->assertEquals([1, 2, 3, 4], $result);
}

/**
* @testdox append entries to a list and return the new list -> empty list to list
*/
public function testAppendEmptyListToNonEmptyList()
public function testAppendEntriesToAListAndReturnTheNewListWithEmptyListToList()
{
$listOps = new ListOps();
$this->assertEquals([1, 2, 3, 4], $listOps->append([], [1, 2, 3, 4]));
$list1 = [1, 2, 3, 4];
$list2 = [];

$result = $listOps->append($list1, $list2);

$this->assertEquals([1, 2, 3, 4], $result);
}

/**
* @testdox append entries to a list and return the new list -> non-empty lists
*/
public function testAppendNonEmptyLists()
public function testAppendEntriesToAListAndReturnTheNewListWithNonEmptyLists()
{
$listOps = new ListOps();
$this->assertEquals([1, 2, 2, 3, 4, 5], $listOps->append([1, 2], [2, 3, 4, 5]));
$list1 = [1, 2];
$list2 = [2, 3, 4, 5];

$result = $listOps->append($list1, $list2);

$this->assertEquals([1, 2, 2, 3, 4, 5], $result);
}

/**
* @testdox concatenate a list of lists -> empty list
*/
public function testConcatEmptyLists()
public function testConcatenateAListOfListsWithEmptyList()
{
$listOps = new ListOps();
$this->assertEquals([], $listOps->concat([], []));
$lists = [];

$result = $listOps->concat($lists);

$this->assertEquals([], $result);
}

/**
* @testdox concatenate a list of lists -> list of lists
*/
public function testConcatLists()
public function testConcatenateAListOfListsWithListOfLists()
{
$listOps = new ListOps();
$this->assertEquals([1, 2, 3, 4, 5, 6], $listOps->concat([1, 2], [3], [], [4, 5, 6]));
$lists = [[1, 2], [3], [], [4, 5, 6]];

$result = $listOps->concat($lists);

$this->assertEquals([1, 2, 3, 4, 5, 6], $result);
}

/**
* @testdox concatenate a list of lists -> list of nested lists
*/
public function testConcatNestedLists()
public function testConcatenateAListOfListsWithListOfNestedLists()
{
$listOps = new ListOps();
$this->assertEquals([[1], [2], [3], [], [4, 5, 6]], $listOps->concat([[1], [2]], [[3]], [[]], [[4, 5, 6]]));
$lists = [[[1], [2]], [[3]], [[]], [[4, 5, 6]]];

$result = $listOps->concat($lists);

$this->assertEquals([[1], [2], [3], [], [4, 5, 6]], $result);
}

/**
* @testdox filter list returning only values that satisfy the filter function -> empty list
*/
public function testFilterEmptyList()
public function testFilterListReturningOnlyValuesThatSatisfyTheFilterFunctionWithEmptyList()
{
$listOps = new ListOps();
$this->assertEquals(
[],
$listOps->filter(static fn ($el) => $el % 2 === 1, [])
);
$list = [];
$function = static fn ($el) => $el % 2 === 1;

$result = $listOps->filter($list, $function);

$this->assertEquals([], $result);
}

/**
* @testdox filter list returning only values that satisfy the filter function -> non empty list
* @testdox filter list returning only values that satisfy the filter function -> non-empty list
*/
public function testFilterNonEmptyList()
public function testFilterListReturningOnlyValuesThatSatisfyTheFilterFunctionWithNonEmptyList()
{
$listOps = new ListOps();
$this->assertEquals(
[1, 3, 5],
$listOps->filter(static fn ($el) => $el % 2 === 1, [1, 2, 3, 5])
);
$list = [1, 2, 3, 5];
$function = static fn ($el) => $el % 2 === 1;

$result = $listOps->filter($list, $function);

$this->assertEquals([1, 3, 5], $result);
}

/**
* @testdox returns the length of a list -> empty list
*/
public function testLengthEmptyList()
public function testReturnsTheLengthOfAListWithEmptyList()
{
$listOps = new ListOps();
$this->assertEquals(0, $listOps->length([]));
$list = [];

$result = $listOps->length($list);

$this->assertEquals(0, $result);
}

/**
* @testdox returns the length of a list -> non-empty list
*/
public function testLengthNonEmptyList()
public function testReturnsTheLengthOfAListWithNonEmptyList()
{
$listOps = new ListOps();
$this->assertEquals(4, $listOps->length([1, 2, 3, 4]));
$list = [1, 2, 3, 4];

$result = $listOps->length($list);

$this->assertEquals(4, $result);
}

/**
* @testdox returns a list of elements whose values equal the list value transformed by the mapping function -> empty list
* @testdox return a list of elements whose values equal the list value transformed by the mapping function -> empty list
*/
public function testMapEmptyList()
public function testReturnAListOfElementsWhoseValuesEqualTheListValueTransformedByTheMappingFunctionWithEmptyList()
{
$listOps = new ListOps();
$this->assertEquals(
[],
$listOps->map(static fn ($el) => $el + 1, [])
);
$list = [];
$function = static fn ($el) => $el + 1;

$result = $listOps->map($list, $function);

$this->assertEquals([], $result);
}

/**
* @testdox returns a list of elements whose values equal the list value transformed by the mapping function -> non-empty list
* @testdox return a list of elements whose values equal the list value transformed by the mapping function -> non-empty list
*/
public function testMapNonEmptyList()
public function testReturnAListOfElementsWhoseValuesEqualTheListValueTransformedByTheMappingFunctionWithNonEmptyList()
{
$listOps = new ListOps();
$this->assertEquals(
[2, 4, 6, 8],
$listOps->map(static fn ($el) => $el + 1, [1, 3, 5, 7])
);
$list = [1, 3, 5, 7];
$function = static fn ($el) => $el + 1;

$result = $listOps->map($list, $function);

$this->assertEquals([2, 4, 6, 8], $result);
}

/**
* @testdox folds (reduces) the given list from the left with a function -> empty list
*/
public function testFoldlEmptyList()
public function testFoldsReducesTheGivenListFromTheLeftWithAFunctionWithEmptyList()
{
$listOps = new ListOps();
$this->assertEquals(
2,
$listOps->foldl(static fn ($acc, $el) => $el * $acc, [], 2)
);
$list = [];
$initial = 2;
$function = static fn ($acc, $el) => $el * $acc;

$result = $listOps->foldl($list, $initial, $function);

$this->assertEquals(2, $result);
}

/**
* @testdox folds (reduces) the given list from the left with a function -> direction independent function applied to non-empty list
*/
public function testFoldlDirectionIndependentNonEmptyList()
public function testFoldsReducesTheGivenListFromTheLeftWithAFunctionWithDirectionIndependentFunctionAppliedToNonEmptyList()
{
$listOps = new ListOps();
$this->assertEquals(
15,
$listOps->foldl(static fn ($acc, $el) => $acc + $el, [1, 2, 3, 4], 5)
);
$list = [1, 2, 3, 4];
$initial = 5;
$function = static fn ($acc, $el) => $el + $acc;

$result = $listOps->foldl($list, $initial, $function);

$this->assertEquals(15, $result);
}

/**
* @testdox folds (reduces) the given list from the left with a function -> direction dependent function applied to non-empty list
*/
public function testFoldlDirectionDependentNonEmptyList()
public function testFoldsReducesTheGivenListFromTheLeftWithAFunctionWithDirectionDependentFunctionAppliedToNonEmptyList()
{
$listOps = new ListOps();
$this->assertEquals(
64,
$listOps->foldl(static fn ($acc, $el) => $el / $acc, [1, 2, 3, 4], 24)
);
$list = [1, 2, 3, 4];
$initial = 24;
$function = static fn ($acc, $el) => $el / $acc;

$result = $listOps->foldl($list, $initial, $function);

$this->assertEquals(64, $result);
}

/**
* @testdox folds (reduces) the given list from the right with a function -> empty list
*/
public function testFoldrEmptyList()
public function testFoldsReducesTheGivenListFromTheRightWithAFunctionWithEmptyList()
{
$listOps = new ListOps();
$this->assertEquals(
2,
$listOps->foldr(static fn ($acc, $el) => $el * $acc, [], 2)
);
$list = [];
$initial = 2;
$function = static fn ($acc, $el) => $el * $acc;

$result = $listOps->foldr($list, $initial, $function);

$this->assertEquals(2, $result);
}

/**
* @testdox folds (reduces) the given list from the right with a function -> direction independent function applied to non-empty list
*/
public function testFoldrDirectionIndependentNonEmptyList()
public function testFoldsReducesTheGivenListFromTheRightWithAFunctionWithDirectionIndependentFunctionAppliedToNonEmptyList()
{
$listOps = new ListOps();
$this->assertEquals(
15,
$listOps->foldr(static fn ($acc, $el) => $acc + $el, [1, 2, 3, 4], 5)
);
$list = [1, 2, 3, 4];
$initial = 5;
$function = static fn ($acc, $el) => $el + $acc;

$result = $listOps->foldr($list, $initial, $function);

$this->assertEquals(15, $result);
}

/**
* @testdox folds (reduces) the given list from the right with a function -> direction dependent function applied to non-empty list
*/
public function testFoldrDirectionDependentNonEmptyList()
public function testFoldsReducesTheGivenListFromTheRightWithAFunctionWithDirectionDependentFunctionAppliedToNonEmptyList()
{
$listOps = new ListOps();
$this->assertEquals(
9,
$listOps->foldr(static fn ($acc, $el) => $el / $acc, [1, 2, 3, 4], 24)
);
$list = [1, 2, 3, 4];
$initial = 24;
$function = static fn ($acc, $el) => $el / $acc;

$result = $listOps->foldr($list, $initial, $function);

$this->assertEquals(9, $result);
}

/**
* @testdox reverse the elements of a list -> empty list
* @testdox reverse the elements of the list -> empty list
*/
public function testReverseEmptyList()
public function testReverseTheElementsOfTheListWithEmptyList()
{
$listOps = new ListOps();
$this->assertEquals([], $listOps->reverse([]));
$list = [];

$result = $listOps->reverse($list);

$this->assertEquals([], $result);
}

/**
* @testdox reverse the elements of a list -> non-empty list
* @testdox reverse the elements of the list -> non-empty list
*/
public function testReverseNonEmptyList()
public function testReverseTheElementsOfTheListWithNonEmptyList()
{
$listOps = new ListOps();
$this->assertEquals([7, 5, 3, 1], $listOps->reverse([1, 3, 5, 7]));
$list = [1, 3, 5, 7];

$result = $listOps->reverse($list);

$this->assertEquals([7, 5, 3, 1], $result);
}

/**
* @testdox reverse the elements of a list -> list of lists is not flattened
* @testdox reverse the elements of the list -> list of lists is not flattened
*/
public function testReverseNonEmptyListIsNotFlattened()
public function testReverseTheElementsOfTheListWithListOfListsIsNotFlattened()
{
$listOps = new ListOps();
$this->assertEquals([[4, 5, 6], [], [3], [1, 2]], $listOps->reverse([[1, 2], [3], [], [4, 5, 6]]));
$list = [[1, 2], [3], [], [4, 5, 6]];

$result = $listOps->reverse($list);

$this->assertEquals([[4, 5, 6], [], [3], [1, 2]], $result);
}

}
}
65 changes: 65 additions & 0 deletions exercises/practice/list-ops/ListOpsTest.php.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php
{% set callbacks = {
'(x) -> x modulo 2 == 1': 'static fn ($el) => $el % 2 === 1',
'(x) -> x + 1': 'static fn ($el) => $el + 1',
'(acc, el) -> el * acc': 'static fn ($acc, $el) => $el * $acc',
'(acc, el) -> el + acc': 'static fn ($acc, $el) => $el + $acc',
'(acc, el) -> el / acc': 'static fn ($acc, $el) => $el / $acc',
}
-%}
/*
* By adding type hints and enabling strict type checking, code can become
* easier to read, self-documenting and reduce the number of potential bugs.
* By default, type declarations are non-strict, which means they will attempt
* to change the original type to match the type specified by the
* type-declaration.
*
* In other words, if you pass a string to a function requiring a float,
* it will attempt to convert the string value to a float.
*
* To enable strict mode, a single declare directive must be placed at the top
* of the file.
* This means that the strictness of typing is configured on a per-file basis.
* This directive not only affects the type declarations of parameters, but also
* a function's return type.
*
* For more info review the Concept on strict type checking in the PHP track
* <link>.
*
* 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';
}
{% for case0 in cases -%}
{% for case in case0.cases -%}
/**
* @testdox {{ case0.description }} -> {{ case.description }}
*/
public function {{ testfn(case0.description ~ ' with ' ~ case.description) }}()
{
$listOps = new ListOps();
{% for property, value in case.input -%}
${{ property }} = {{ property == 'function' ? callbacks[value] : export(value) }};
{% endfor %}
$result = $listOps->{{ case.property }}({{ case.input | keys | map(p => '$' ~ p) | join(', ')}});
$this->assertEquals({{ export(case.expected) }}, $result);
}
{% endfor -%}
{% endfor -%}
}
3 changes: 3 additions & 0 deletions test-generator/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.phpunit.cache/
.phpcs-cache
vendor/
26 changes: 26 additions & 0 deletions test-generator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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
- [ ] TOML Library for php (does not seem to exist any maitained library)
- [ ] Default templates:
- [ ] Test function header (automatic docblock, automatic name)
- [ ] Going further
- [ ] Skip re-implements
- [x] Read .meta/tests.toml to skip `include=false` cases by uuid
- [ ] Ensure correctness between toml and effectively generated files
- [ ] 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
- [ ] Automatically fetch configlet and exercise informations
- [x] Disable twig automatic isset
41 changes: 41 additions & 0 deletions test-generator/composer.json
Original file line number Diff line number Diff line change
@@ -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
}
}
8 changes: 8 additions & 0 deletions test-generator/main.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

require __DIR__ . '/vendor/autoload.php';

use App\Application;

$application = new Application();
$application->run();
37 changes: 37 additions & 0 deletions test-generator/phpcs.xml.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>

<ruleset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/squizlabs/php_codesniffer/phpcs.xsd">

<arg name="basepath" value="."/>
<arg name="cache" value=".phpcs-cache"/>
<arg name="colors"/>
<arg name="extensions" value="php"/>
<arg name="parallel" value="60"/>
<config name="installed_paths" value="vendor/doctrine/coding-standard/lib,vendor/slevomat/coding-standard"/>
<!-- Show progress of the run and show sniff names -->
<arg value="ps"/>

<!-- Include full Doctrine Coding Standard -->
<rule ref="Doctrine"/>

<!-- Include custom rules -->
<rule ref="Squiz.WhiteSpace.OperatorSpacing">
<properties>
<property name="ignoreNewlines" value="true" />
<property name="ignoreSpacingBeforeAssignments" value="false" />
</properties>
</rule>

<!-- Exclude some rules -->
<rule ref="SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed">
<exclude name="SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed"/>
</rule>
<rule ref="Generic.Formatting.MultipleStatementAlignment.NotSame">
<exclude name="Generic.Formatting.MultipleStatementAlignment.NotSame"/>
</rule>

<!-- Directories to be checked -->
<file>src/</file>
<file>tests/</file>
</ruleset>
2 changes: 2 additions & 0 deletions test-generator/phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
parameters:
level: max
24 changes: 24 additions & 0 deletions test-generator/phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.1/phpunit.xsd"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="random">
<testsuites>
<testsuite name="PHP Representer Test Suite">
<directory>./tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src/</directory>
</include>
</source>
<coverage>
<report>
<clover outputFile="build/logs/clover.xml"/>
<html outputDirectory="build/coverage"/>
<text outputFile="php://stdout"/>
</report>
</coverage>
</phpunit>
150 changes: 150 additions & 0 deletions test-generator/src/Application.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php

declare(strict_types=1);

namespace App;

use Brick\VarExporter\VarExporter;
use Exception;
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Logger\ConsoleLogger;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\SingleCommandApplication;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use Twig\TwigFunction;

use function array_filter;
use function array_key_exists;
use function assert;
use function file_get_contents;
use function implode;
use function is_array;
use function is_bool;
use function is_string;
use function json_decode;
use function preg_replace;
use function str_replace;
use function ucwords;

use const JSON_THROW_ON_ERROR;

class Application extends SingleCommandApplication
{
public function __construct()
{
parent::__construct('Exercism PHP Test Generator');
}

protected function configure(): void
{
parent::configure();

$this->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<string, mixed> $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. Read test.toml
$testsToml = $exerciseDir->read('/.meta/tests.toml');
$tests = TomlParser::parse($testsToml);

// 3. Remove `include = false` tests
$excludedTests = array_filter($tests, static fn (array $props) => isset($props['include']) && $props['include'] === false);
$this->removeExcludedTests($excludedTests, $canonicalData['cases']);

// 4. foreach tests files, check if there is a twig file
$twigLoader = new ArrayLoader();
$twigEnvironment = new Environment($twigLoader, ['strict_variables' => true, '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) {
// 5. 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) {
// 6. 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;
}

private function removeExcludedTests(array $tests, array &$cases): void
{
foreach ($cases as $key => &$case) {
if (array_key_exists('cases', $case)) {
$this->removeExcludedTests($tests, $case['cases']);
} else {
assert(array_key_exists('uuid', $case));
if (array_key_exists($case['uuid'], $tests)) {
unset($cases[$key]);
}
}
}
}
}
79 changes: 79 additions & 0 deletions test-generator/src/TomlParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace App;

use function explode;
use function intval;
use function is_numeric;
use function str_ends_with;
use function str_starts_with;
use function strtolower;
use function substr;
use function trim;

/**
* A really basic TOML parser that handles enough of the syntax used by Exercism
*
* @see https://toml.io/en/v1.0.0
*/
class TomlParser
{
public static function parse(string $tomlString): array
{
$lines = explode("\n", $tomlString);
$data = [];
$currentTable = null;

foreach ($lines as $line) {
$line = trim($line);

// Skip empty lines and comments
if (empty($line) || $line[0] === '#') {
continue;
}

// Check for table declaration
if (str_starts_with($line, '[')) {
$tableName = trim(substr($line, 1, -1));
if (! isset($data[$tableName])) {
$data[$tableName] = [];
}

$currentTable = &$data[$tableName];
continue;
}

// @TODO Handle quoted keys, handle doted keys
// Parse key-value pair
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);

// @TODO: Handle multi-line string, literal string and multi-line literal string
if (str_starts_with($value, '"') && str_ends_with($value, '"')) {
// Handle quoted strings
$value = substr($value, 1, -1);
} elseif (is_numeric($value)) {
// Handle integer
$value = intval($value);
} elseif (strtolower($value) === 'true') {
// Handle boolean true
$value = true;
} elseif (strtolower($value) === 'false') {
// Handle boolean false
$value = false;
}

// Assign value to current table or root data
if ($currentTable !== null) {
$currentTable[$key] = $value;
} else {
$data[$key] = $value;
}
}

return $data;
}
}
33 changes: 33 additions & 0 deletions test-generator/tests/ApplicationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace App\Tests;

use App\Application;
use League\Flysystem\Filesystem;
use League\Flysystem\InMemory\InMemoryFilesystemAdapter;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;

class ApplicationTest extends TestCase
{
/**
* @TODO Correct integration test
*/
public function testGenerate(): void
{
$exercise = new InMemoryFilesystemAdapter();
$exerciseFs = new Filesystem($exercise);
$exerciseFs->write('.meta/config.json', '{"files":{"test":["test.php"]}}');
$exerciseFs->write('.meta/tests.toml', '');
$exerciseFs->write('test.php.twig', '<?php $a = {{ export(a) }}; $b = "{{ testfn(l) }}";');
$canonicalData = ['a' => [1, 2], 'l' => 'this-Is_a test fn', 'cases' => []];

$application = new Application();
$success = $application->generate($exerciseFs, false, $canonicalData, new NullLogger());

$this->assertTrue($success);
$this->assertSame('<?php $a = [1, 2]; $b = "testThisIsATestFn";', $exerciseFs->read('/test.php'));
}
}