diff --git a/.gitattributes b/.gitattributes index 30d509a..214989f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,7 +10,8 @@ /docker-compose.yml export-ignore /packaging_exclude.php export-ignore /phpstan.neon export-ignore -/phpunit.xml export-ignore +/phpunit.functional.xml export-ignore +/phpunit.unit.xml export-ignore /rector.php export-ignore /renovate.json export-ignore /typoscript-lint.yml export-ignore diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 68d0035..dba5f48 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -19,6 +19,11 @@ jobs: typo3-version: ["12.4"] php-version: ["8.1", "8.2", "8.3"] dependencies: ["highest", "lowest"] + env: + typo3DatabaseName: typo3 + typo3DatabaseHost: '127.0.0.1' + typo3DatabaseUsername: root + typo3DatabasePassword: root steps: - uses: actions/checkout@v4 with: @@ -32,6 +37,10 @@ jobs: tools: composer:v2 coverage: none + # Start MySQL service + - name: Start MySQL + run: sudo /etc/init.d/mysql start + # Install dependencies - name: Install Composer dependencies uses: ramsey/composer-install@v2 @@ -46,6 +55,11 @@ jobs: coverage: name: Test coverage runs-on: ubuntu-latest + env: + typo3DatabaseName: typo3 + typo3DatabaseHost: '127.0.0.1' + typo3DatabaseUsername: root + typo3DatabasePassword: root steps: - uses: actions/checkout@v4 with: @@ -59,6 +73,10 @@ jobs: tools: composer:v2 coverage: pcov + # Start MySQL service + - name: Start MySQL + run: sudo /etc/init.d/mysql start + # Install dependencies - name: Install Composer dependencies uses: ramsey/composer-install@v2 @@ -69,13 +87,13 @@ jobs: # Upload artifact - name: Fix coverage path - working-directory: .Build/log/coverage + working-directory: .Build/coverage run: sed -i 's#/home/runner/work/handlebars/handlebars#${{ github.workspace }}#g' clover.xml - name: Upload coverage artifact uses: actions/upload-artifact@v4 with: name: coverage - path: .Build/log/coverage/clover.xml + path: .Build/coverage/clover.xml retention-days: 7 coverage-report: diff --git a/Classes/Renderer/Template/FlatTemplateResolver.php b/Classes/Renderer/Template/FlatTemplateResolver.php new file mode 100644 index 0000000..bee756a --- /dev/null +++ b/Classes/Renderer/Template/FlatTemplateResolver.php @@ -0,0 +1,146 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace Fr\Typo3Handlebars\Renderer\Template; + +use Fr\Typo3Handlebars\Exception; +use Symfony\Component\Finder; + +/** + * FlatTemplateResolver + * + * @author Elias Häußler + * @license GPL-2.0-or-later + * @see https://fractal.build/guide/core-concepts/naming.html + */ +class FlatTemplateResolver extends HandlebarsTemplateResolver +{ + protected const VARIANT_SEPARATOR = '--'; + + /** + * @var array + */ + protected array $flattenedTemplates = []; + protected int $depth = 30; + + public function __construct( + TemplatePaths $templateRootPaths, + array $supportedFileExtensions = self::DEFAULT_FILE_EXTENSIONS, + ) { + parent::__construct($templateRootPaths, $supportedFileExtensions); + $this->buildTemplateMap(); + } + + public function resolveTemplatePath(string $templatePath): string + { + // Use default path resolving if path is not prefixed by "@" + if (!str_starts_with($templatePath, '@')) { + return parent::resolveTemplatePath($templatePath); + } + + // Strip "@" prefix from given template path + $templateName = ltrim($templatePath, '@'); + + // Return filename if template exists + if (isset($this->flattenedTemplates[$templateName])) { + return $this->flattenedTemplates[$templateName]->getPathname(); + } + + // Strip off template variant + if (str_contains($templateName, self::VARIANT_SEPARATOR)) { + [$templateName] = explode(self::VARIANT_SEPARATOR, $templateName, 2); + + if (isset($this->flattenedTemplates[$templateName])) { + return $this->flattenedTemplates[$templateName]->getPathname(); + } + } + + throw new Exception\TemplateNotFoundException($templateName, 1628256108); + } + + protected function buildTemplateMap(): void + { + // Reset flattened templates + $this->flattenedTemplates = []; + + // Instantiate finder + $finder = new Finder\Finder(); + $finder->files(); + $finder->name([...$this->buildExtensionPatterns()]); + $finder->depth(sprintf('< %d', $this->depth)); + + // Explicitly sort files and directories by name in order to streamline ordering + // with logic used in Fractal to ensure that the first occurrence of a flattened + // file is always used instead of relying on random behavior, + // see https://fractal.build/guide/core-concepts/naming.html#uniqueness + $finder->sortByName(); + + // Build template map + foreach ($this->templateRootPaths as $templateRootPath) { + $path = $this->resolveFilename($templateRootPath); + $pathFinder = clone $finder; + $pathFinder->in($path); + + foreach ($pathFinder as $file) { + if ($this->isFirstOccurrenceInTemplateRoot($file)) { + $this->registerTemplate($file); + } + } + } + } + + protected function isFirstOccurrenceInTemplateRoot(Finder\SplFileInfo $file): bool + { + $filename = $this->resolveFlatFilename($file); + + // Early return if template is not registered yet + if (!isset($this->flattenedTemplates[$filename])) { + return true; + } + + // In order to streamline template file flattening with logic used in Fractal, + // we always use the first flat file occurrence as resolved template, but provide + // the option to override exactly this file within other template root paths. + return $this->flattenedTemplates[$filename]->getRelativePathname() === $file->getRelativePathname(); + } + + protected function registerTemplate(Finder\SplFileInfo $file): void + { + $this->flattenedTemplates[$this->resolveFlatFilename($file)] = $file; + } + + protected function resolveFlatFilename(Finder\SplFileInfo $file): string + { + return pathinfo($file->getPathname(), PATHINFO_FILENAME); + } + + /** + * @return \Generator + */ + protected function buildExtensionPatterns(): \Generator + { + foreach ($this->supportedFileExtensions as $extension) { + yield sprintf('*.%s', $extension); + } + } +} diff --git a/Documentation/Contributing/Index.rst b/Documentation/Contributing/Index.rst index 57f1273..5ed3794 100644 --- a/Documentation/Contributing/Index.rst +++ b/Documentation/Contributing/Index.rst @@ -104,13 +104,29 @@ Run tests .. code-block:: bash - # Run tests + # All tests composer test - # Run tests with code coverage + # Specific tests + composer test:functional + composer test:unit + + # All tests with code coverage composer test:coverage -The code coverage reports will be stored in :file:`.Build/log/coverage`. + # Specific tests with code coverage + composer test:coverage:functional + composer test:coverage:unit + + # Merge code coverage of all test suites + composer test:coverage:merge + +Code coverage reports are written to :file:`.Build/coverage`. You can +open the last merged HTML report like follows: + +.. code-block:: bash + + open .Build/coverage/html/_merged/index.html .. _build-documentation: diff --git a/Tests/Functional/Fixtures/test_extension/Resources/Templates/main-layout--variant.hbs b/Tests/Functional/Fixtures/test_extension/Resources/Templates/main-layout--variant.hbs new file mode 100644 index 0000000..033b31c --- /dev/null +++ b/Tests/Functional/Fixtures/test_extension/Resources/Templates/main-layout--variant.hbs @@ -0,0 +1,25 @@ +this is the main block: + +{{#block "main"}} + main block +{{/block}} + +this is the second block: + +{{#block "second"}} + second block +{{/block}} + +this is the third block: + +{{#block "third"}} + third block +{{/block}} + +this is the fourth block: + +{{#block "fourth"}} + fourth block +{{/block}} + +this is the end. bye bye diff --git a/Tests/Functional/Fixtures/test_extension/Resources/Templates/main-layout.hbs b/Tests/Functional/Fixtures/test_extension/Resources/Templates/main-layout.hbs new file mode 100644 index 0000000..033b31c --- /dev/null +++ b/Tests/Functional/Fixtures/test_extension/Resources/Templates/main-layout.hbs @@ -0,0 +1,25 @@ +this is the main block: + +{{#block "main"}} + main block +{{/block}} + +this is the second block: + +{{#block "second"}} + second block +{{/block}} + +this is the third block: + +{{#block "third"}} + third block +{{/block}} + +this is the fourth block: + +{{#block "fourth"}} + fourth block +{{/block}} + +this is the end. bye bye diff --git a/Tests/Functional/Fixtures/test_extension/composer.json b/Tests/Functional/Fixtures/test_extension/composer.json new file mode 100644 index 0000000..500ddc3 --- /dev/null +++ b/Tests/Functional/Fixtures/test_extension/composer.json @@ -0,0 +1,20 @@ +{ + "name": "cpsit/typo3-handlebars-test-extension", + "description": "Test extension for EXT:handlebars", + "license": "GPL-2.0-or-later", + "type": "typo3-cms-extension", + "version": "1.0.0", + "require": { + "typo3/cms-core": "*" + }, + "autoload": { + "psr-4": { + "Fr\\Typo3Handlebars\\TestExtension\\": "Classes/" + } + }, + "extra": { + "typo3/cms": { + "extension-key": "test_extension" + } + } +} diff --git a/Tests/Functional/Renderer/Template/FlatTemplateResolverTest.php b/Tests/Functional/Renderer/Template/FlatTemplateResolverTest.php new file mode 100644 index 0000000..46db707 --- /dev/null +++ b/Tests/Functional/Renderer/Template/FlatTemplateResolverTest.php @@ -0,0 +1,75 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace Fr\Typo3Handlebars\Tests\Functional\Renderer\Template; + +use Fr\Typo3Handlebars as Src; +use Fr\Typo3Handlebars\Tests; +use PHPUnit\Framework; +use TYPO3\TestingFramework; + +/** + * FlatTemplateResolverTest + * + * @author Elias Häußler + * @license GPL-2.0-or-later + */ +#[Framework\Attributes\CoversClass(Src\Renderer\Template\FlatTemplateResolver::class)] +final class FlatTemplateResolverTest extends TestingFramework\Core\Functional\FunctionalTestCase +{ + use Tests\Unit\HandlebarsTemplateResolverTrait; + + protected array $testExtensionsToLoad = [ + 'test_extension', + ]; + + protected bool $initializeDatabase = false; + + protected function setUp(): void + { + parent::setUp(); + + $this->templateResolver = new Src\Renderer\Template\FlatTemplateResolver($this->getTemplatePaths()); + } + + #[Framework\Attributes\Test] + public function resolveTemplatePathRespectsTemplateVariant(): void + { + $expected = $this->instancePath . '/typo3conf/ext/test_extension/Resources/Templates/main-layout--variant.hbs'; + + self::assertSame($expected, $this->templateResolver->resolveTemplatePath('@main-layout--variant')); + } + + #[Framework\Attributes\Test] + public function resolveTemplatePathReturnsBaseTemplateForNonExistingTemplateVariant(): void + { + $expected = $this->instancePath . '/typo3conf/ext/test_extension/Resources/Templates/main-layout.hbs'; + + self::assertSame($expected, $this->templateResolver->resolveTemplatePath('@main-layout--non-existing-variant')); + } + + public function getTemplateRootPath(): string + { + return 'EXT:test_extension/Resources/Templates/'; + } +} diff --git a/Tests/Unit/HandlebarsTemplateResolverTrait.php b/Tests/Unit/HandlebarsTemplateResolverTrait.php index 208f7b9..18cfd5f 100644 --- a/Tests/Unit/HandlebarsTemplateResolverTrait.php +++ b/Tests/Unit/HandlebarsTemplateResolverTrait.php @@ -28,7 +28,8 @@ use Fr\Typo3Handlebars\Renderer\Template\TemplateResolverInterface; use Fr\Typo3Handlebars\Tests\Unit\Fixtures\Classes\DummyConfigurationManager; use Fr\Typo3Handlebars\Tests\Unit\Fixtures\Classes\Renderer\Template\DummyTemplatePaths; -use Symfony\Component\DependencyInjection\Container; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; /** * HandlebarsTemplateResolverTrait @@ -76,21 +77,16 @@ protected function getPartialResolver(): TemplateResolverInterface protected function getTemplatePaths(string $type = TemplatePaths::TEMPLATES): DummyTemplatePaths { - $container = $this->getContainer($type); - - $templatePaths = new DummyTemplatePaths(new DummyConfigurationManager(), $type); - $templatePaths->setContainer($container); - - return $templatePaths; + return new DummyTemplatePaths(new DummyConfigurationManager(), $this->getParameterBag($type), $type); } - protected function getContainer(string $type = TemplatePaths::TEMPLATES): Container + protected function getParameterBag(string $type = TemplatePaths::TEMPLATES): ParameterBagInterface { $templateRootPath = $type === TemplatePaths::PARTIALS ? $this->getPartialRootPath() : $this->getTemplateRootPath(); - $container = new Container(); - $container->setParameter('handlebars.' . $type, [10 => $templateRootPath]); - return $container; + return new ParameterBag([ + 'handlebars.' . $type => [10 => $templateRootPath], + ]); } public function getTemplateRootPath(): string diff --git a/Tests/Unit/Renderer/Template/TemplatePathsTest.php b/Tests/Unit/Renderer/Template/TemplatePathsTest.php index b7a9220..bd60676 100644 --- a/Tests/Unit/Renderer/Template/TemplatePathsTest.php +++ b/Tests/Unit/Renderer/Template/TemplatePathsTest.php @@ -52,8 +52,7 @@ protected function setUp(): void { parent::setUp(); $this->configurationManager = new DummyConfigurationManager(); - $this->subject = new TemplatePaths($this->configurationManager); - $this->subject->setContainer($this->getContainer()); + $this->subject = new TemplatePaths($this->configurationManager, $this->getParameterBag()); } /** diff --git a/composer.json b/composer.json index 7a2866e..0bc4f8e 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "psr/log": "^1.1 || ^2.0 || ^3.0", "symfony/config": "^6.4 || ^7.0", "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/finder": "^6.4 || ^7.0", "typo3/cms-core": "^12.4", "typo3/cms-extbase": "^12.4", "typo3/cms-fluid": "^12.4", @@ -33,6 +34,7 @@ }, "require-dev": { "armin/editorconfig-cli": "^1.8 || ^2.0", + "cpsit/typo3-handlebars-test-extension": "1.0.0", "ergebnis/composer-normalize": "^2.15", "friendsofphp/php-cs-fixer": "^3.57", "helmich/typo3-typoscript-lint": "^2.5 || ^3.0", @@ -40,6 +42,7 @@ "phpstan/extension-installer": "^1.3", "phpstan/phpstan": "^1.9", "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpcov": "^9.0 || ^10.0", "phpunit/phpunit": "^10.1 || ^11.0", "saschaegerer/phpstan-typo3": "^1.0", "ssch/typo3-rector": "^2.0", @@ -47,6 +50,12 @@ "typo3/coding-standards": "^0.8.0", "typo3/testing-framework": "^8.0" }, + "repositories": [ + { + "type": "path", + "url": "Tests/Functional/Fixtures/*" + } + ], "autoload": { "psr-4": { "Fr\\Typo3Handlebars\\": "Classes/" @@ -113,7 +122,19 @@ "@sca:php" ], "sca:php": "phpstan analyse -c phpstan.neon", - "test": "@test:coverage --no-coverage", - "test:coverage": "phpunit -c phpunit.xml" + "test": [ + "@test:functional", + "@test:unit" + ], + "test:coverage": [ + "@test:coverage:functional", + "@test:coverage:unit", + "@test:coverage:merge" + ], + "test:coverage:functional": "phpunit -c phpunit.functional.xml", + "test:coverage:merge": "phpcov merge --html .Build/coverage/html/_merged --clover .Build/coverage/clover.xml --text php://stdout .Build/coverage/php", + "test:coverage:unit": "phpunit -c phpunit.unit.xml", + "test:functional": "@test:coverage:functional --no-coverage", + "test:unit": "@test:coverage:unit --no-coverage" } } diff --git a/composer.lock b/composer.lock index 4b398a9..5c9699e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5d04f6d9496fac18b1ad38752eed8e8a", + "content-hash": "c41bb03f6dab0a8653d8ae9d7a1050d8", "packages": [ { "name": "bacon/bacon-qr-code", @@ -5935,6 +5935,36 @@ ], "time": "2024-05-06T16:37:16+00:00" }, + { + "name": "cpsit/typo3-handlebars-test-extension", + "version": "1.0.0", + "dist": { + "type": "path", + "url": "Tests/Functional/Fixtures/test_extension", + "reference": "df86f70cfd3060828c241882cafe72cf35b25c9f" + }, + "require": { + "typo3/cms-core": "*" + }, + "type": "typo3-cms-extension", + "extra": { + "typo3/cms": { + "extension-key": "test_extension" + } + }, + "autoload": { + "psr-4": { + "Fr\\Typo3Handlebars\\TestExtension\\": "Classes/" + } + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Test extension for EXT:handlebars", + "transport-options": { + "relative": true + } + }, { "name": "ergebnis/composer-normalize", "version": "2.43.0", @@ -7948,6 +7978,68 @@ ], "time": "2023-02-03T06:57:52+00:00" }, + { + "name": "phpunit/phpcov", + "version": "9.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpcov.git", + "reference": "05307478b8f4b2a50c508d6f4eca15704cf7c1fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpcov/zipball/05307478b8f4b2a50c508d6f4eca15704cf7c1fd", + "reference": "05307478b8f4b2a50c508d6f4eca15704cf7c1fd", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.5", + "phpunit/php-file-iterator": "^4.0", + "phpunit/phpunit": "^10.0", + "sebastian/cli-parser": "^2.0", + "sebastian/diff": "^5.0", + "sebastian/version": "^4.0" + }, + "bin": [ + "phpcov" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "CLI frontend for php-code-coverage", + "homepage": "https://github.com/sebastianbergmann/phpcov", + "support": { + "issues": "https://github.com/sebastianbergmann/phpcov/issues", + "source": "https://github.com/sebastianbergmann/phpcov/tree/9.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-09-12T14:47:35+00:00" + }, { "name": "phpunit/phpunit", "version": "10.5.34", diff --git a/packaging_exclude.php b/packaging_exclude.php index f4fc9b8..b2eef6c 100644 --- a/packaging_exclude.php +++ b/packaging_exclude.php @@ -41,7 +41,8 @@ 'packaging_exclude.php', 'php-cs-fixer.php', 'phpstan.neon', - 'phpunit.xml', + 'phpunit.functional.xml', + 'phpunit.unit.xml', 'rector.php', 'renovate.json', 'typoscript-lint.yml', diff --git a/phpunit.functional.xml b/phpunit.functional.xml new file mode 100644 index 0000000..ad99628 --- /dev/null +++ b/phpunit.functional.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + Tests/Functional + + + + + + + + Classes + + + diff --git a/phpunit.xml b/phpunit.unit.xml similarity index 72% rename from phpunit.xml rename to phpunit.unit.xml index bc6d10f..5c956db 100644 --- a/phpunit.xml +++ b/phpunit.unit.xml @@ -7,9 +7,9 @@ > - - - + + + @@ -18,7 +18,7 @@ - + diff --git a/renovate.json b/renovate.json index 51d4699..9098a6d 100644 --- a/renovate.json +++ b/renovate.json @@ -13,5 +13,18 @@ ], "constraints": { "php": "8.1.*" - } + }, + "packageRules": [ + { + "extends": [ + ":disableRenovate" + ], + "matchDatasources": [ + "packagist" + ], + "matchPackageNames": [ + "cpsit/typo3-handlebars-test-extension" + ] + } + ] }