From 7170a8390b53651a62ae03150d3cfdbbc987f713 Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Thu, 19 Dec 2024 00:25:51 +0100 Subject: [PATCH 1/3] Refactored --- src/Admin/Field/ValueWithUnitField.php | 12 ++ .../Admin/PointOfInterestCrudController.php | 10 +- src/Controller/Admin/RouteCrudController.php | 32 ++--- src/Form/ValueWithUnitType.php | 101 +++------------ src/Service/ValueWithUnitHelper.php | 118 ++++++++++++++++++ src/Twig/Runtime/AppExtensionRuntime.php | 5 +- 6 files changed, 173 insertions(+), 105 deletions(-) create mode 100644 src/Service/ValueWithUnitHelper.php diff --git a/src/Admin/Field/ValueWithUnitField.php b/src/Admin/Field/ValueWithUnitField.php index 5bea65f..f998e39 100644 --- a/src/Admin/Field/ValueWithUnitField.php +++ b/src/Admin/Field/ValueWithUnitField.php @@ -3,6 +3,7 @@ namespace App\Admin\Field; use App\Form\ValueWithUnitType; +use App\Service\ValueWithUnitHelper; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface; use EasyCorp\Bundle\EasyAdminBundle\Field\FieldTrait; use Symfony\Contracts\Translation\TranslatableInterface; @@ -19,6 +20,7 @@ public static function new(string $propertyName, $label = null): self return (new self()) ->setProperty($propertyName) ->setLabel($label) + ->setScale(0) // this template is used in 'index' and 'detail' pages ->setTemplatePath('admin/field/value_with_unit.html.twig') @@ -28,4 +30,14 @@ public static function new(string $propertyName, $label = null): self ->setFormType(ValueWithUnitType::class) ->addCssClass('field-value-with-unit'); } + + public function setUnits(array $units): self + { + return $this->setFormTypeOption(ValueWithUnitHelper::OPTION_UNITS, $units); + } + + public function setScale(int $scale): self + { + return $this->setFormTypeOption(ValueWithUnitHelper::OPTION_SCALE, $scale); + } } diff --git a/src/Controller/Admin/PointOfInterestCrudController.php b/src/Controller/Admin/PointOfInterestCrudController.php index 0d9077a..9c94c29 100644 --- a/src/Controller/Admin/PointOfInterestCrudController.php +++ b/src/Controller/Admin/PointOfInterestCrudController.php @@ -8,8 +8,8 @@ use App\Entity\Role; use App\Entity\Route; use App\Field\VichImageField; -use App\Form\ValueWithUnitType; use App\Service\EasyAdminHelper; +use App\Service\ValueWithUnitHelper; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField; @@ -94,11 +94,11 @@ public function configureFields(string $pageName): iterable ->setVirtual(true)->setColumns(12); yield ValueWithUnitField::new('proximityToUnlock', new TranslatableMessage('Proximity to unlock', [], 'admin')) - ->setFormTypeOption('units', [ + ->setUnits([ 'm' => [ - ValueWithUnitType::OPTION_LABEL => new TranslatableMessage('meter', [], 'admin'), - ValueWithUnitType::OPTION_SCALE => 1, - ValueWithUnitType::OPTION_LOCALIZED_UNIT => new TranslatableMessage('unit.m', [], 'admin'), + ValueWithUnitHelper::OPTION_UNIT_LABEL => new TranslatableMessage('meter', [], 'admin'), + ValueWithUnitHelper::OPTION_SCALE => 1, + ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => new TranslatableMessage('unit.m', [], 'admin'), ], ]) ->setHelp(new TranslatableMessage('The proximity that allows unlocking this point of interest.', [], 'admin'))->setColumns(12); diff --git a/src/Controller/Admin/RouteCrudController.php b/src/Controller/Admin/RouteCrudController.php index a354261..30d3163 100644 --- a/src/Controller/Admin/RouteCrudController.php +++ b/src/Controller/Admin/RouteCrudController.php @@ -6,9 +6,9 @@ use App\Entity\Role; use App\Entity\Route; use App\Field\VichImageField; -use App\Form\ValueWithUnitType; use App\Service\AppManager; use App\Service\EasyAdminHelper; +use App\Service\ValueWithUnitHelper; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; @@ -80,32 +80,34 @@ public function configureFields(string $pageName): iterable } yield ValueWithUnitField::new('distance', new TranslatableMessage('Distance', [], 'admin')) - ->setFormTypeOption('units', [ + ->setScale(1) + ->setUnits([ 'km' => [ - ValueWithUnitType::OPTION_LABEL => new TranslatableMessage('kilometer', [], 'admin'), - ValueWithUnitType::OPTION_SCALE => 1000, - ValueWithUnitType::OPTION_LOCALIZED_UNIT => new TranslatableMessage('unit.km', [], 'admin'), + ValueWithUnitHelper::OPTION_UNIT_LABEL => new TranslatableMessage('kilometer', [], 'admin'), + ValueWithUnitHelper::OPTION_SCALE => 1000, + ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => new TranslatableMessage('unit.km', [], 'admin'), ], 'm' => [ - ValueWithUnitType::OPTION_LABEL => new TranslatableMessage('meter', [], 'admin'), - ValueWithUnitType::OPTION_SCALE => 1, - ValueWithUnitType::OPTION_LOCALIZED_UNIT => new TranslatableMessage('unit.m', [], 'admin'), + ValueWithUnitHelper::OPTION_UNIT_LABEL => new TranslatableMessage('meter', [], 'admin'), + ValueWithUnitHelper::OPTION_SCALE => 1, + ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => new TranslatableMessage('unit.m', [], 'admin'), ], ]) ->setColumns(6) ->setHelp(new TranslatableMessage('The total distance of the route with all points of interests included.', [], 'admin')); yield ValueWithUnitField::new('totalDuration', new TranslatableMessage('Total duration', [], 'admin')) - ->setFormTypeOption('units', [ + ->setScale(1) + ->setUnits([ 'hour' => [ - ValueWithUnitType::OPTION_LABEL => new TranslatableMessage('hours', [], 'admin'), - ValueWithUnitType::OPTION_SCALE => 60 * 60, - ValueWithUnitType::OPTION_LOCALIZED_UNIT => new TranslatableMessage('unit.hour', [], 'admin'), + ValueWithUnitHelper::OPTION_UNIT_LABEL => new TranslatableMessage('hours', [], 'admin'), + ValueWithUnitHelper::OPTION_SCALE => 60 * 60, + ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => new TranslatableMessage('unit.hour', [], 'admin'), ], 'minute' => [ - ValueWithUnitType::OPTION_LABEL => new TranslatableMessage('minutes', [], 'admin'), - ValueWithUnitType::OPTION_SCALE => 60, - ValueWithUnitType::OPTION_LOCALIZED_UNIT => new TranslatableMessage('unit.minute', [], 'admin'), + ValueWithUnitHelper::OPTION_UNIT_LABEL => new TranslatableMessage('minutes', [], 'admin'), + ValueWithUnitHelper::OPTION_SCALE => 60, + ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => new TranslatableMessage('unit.minute', [], 'admin'), ], ]) ->setColumns(6) diff --git a/src/Form/ValueWithUnitType.php b/src/Form/ValueWithUnitType.php index d8bb7b6..9c61c3e 100644 --- a/src/Form/ValueWithUnitType.php +++ b/src/Form/ValueWithUnitType.php @@ -2,6 +2,7 @@ namespace App\Form; +use App\Service\ValueWithUnitHelper; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\CallbackTransformer; use Symfony\Component\Form\Exception\TransformationFailedException; @@ -9,11 +10,7 @@ use Symfony\Component\Form\Extension\Core\Type\NumberType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; -use Symfony\Component\Translation\LocaleSwitcher; use Symfony\Component\Translation\TranslatableMessage; -use Symfony\Component\Validator\Constraints\Positive; -use Symfony\Component\Validator\Validation; -use Symfony\Contracts\Translation\TranslatorInterface; /** * @extends AbstractType @@ -23,124 +20,62 @@ final class ValueWithUnitType extends AbstractType public const string FIELD_VALUE = 'value'; public const string FIELD_UNIT = 'unit'; - public const string OPTION_LABEL = 'label'; - public const string OPTION_SCALE = 'scale'; - public const string OPTION_LOCALIZED_UNIT = 'localized_unit'; - - private const int SCALE = 1; - - private \NumberFormatter $numberFormatter; - public function __construct( - private readonly TranslatorInterface $translator, - LocaleSwitcher $localeSwitcher, + private ValueWithUnitHelper $helper, ) { - $this->numberFormatter = new \NumberFormatter($localeSwitcher->getLocale(), \NumberFormatter::DECIMAL); - $this->numberFormatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, self::SCALE); } public function buildForm(FormBuilderInterface $builder, array $options): void { + $this->helper = $this->helper->withOptions($options); + $units = array_keys($options['units']); $unitChoices = array_combine($units, $units); $builder ->add(self::FIELD_VALUE, NumberType::class, [ - 'scale' => self::SCALE, + 'scale' => $this->helper->getScale(), ]) ->add(self::FIELD_UNIT, ChoiceType::class, [ 'choices' => $unitChoices, - 'choice_label' => function ($choice, string $key, mixed $value) use ($options): TranslatableMessage|string { + 'choice_label' => function ($choice, string $key, mixed $value) use ($options + ): TranslatableMessage|string { return $options['units'][$key]['label'] ?? $key; }, ]); $builder ->addModelTransformer(new CallbackTransformer( - fn ($value) => $this->transform($value, $options), - fn ($value) => $this->reverseTransform($value, $options), - )) - ; + $this->transform(...), + $this->reverseTransform(...), + )); } - public function transform(?int $value, array $options): array + public function transform(?int $value): array { try { - return $this->getMatchingUnit($value, $options); - } catch (\Exception $exception) { + return $this->helper->getMatchingUnit($value); + } catch (\Exception) { throw new TransformationFailedException(invalidMessage: 'Error transforming value: {value}.', invalidMessageParameters: ['value' => $value, /* @todo Make this work! */ 'translation_domain' => 'admin']); } } - public function reverseTransform(array $values, array $options): int + public function reverseTransform(array $values): int { [self::FIELD_VALUE => $value, self::FIELD_UNIT => $unit] = $values; - $units = $this->getUnits($options); + $units = $this->helper->getUnits(); $info = $units[$unit] ?? null; if (null === $info) { throw new TransformationFailedException(invalidMessage: 'Invalid unit: {unit}.', invalidMessageParameters: ['unit' => $unit, /* @todo Make this work! */ 'translation_domain' => 'admin']); } - return $value * $info[self::OPTION_SCALE]; + return $value * $this->helper->getScale(); } public function configureOptions(OptionsResolver $resolver): void { $resolver - ->setRequired('units') - ->setAllowedTypes('units', 'array') - ->setDefault('units', function (OptionsResolver $resolver): void { - $resolver - ->setPrototype(true) - ->setDefault(self::OPTION_SCALE, 1) - ->setAllowedTypes(self::OPTION_SCALE, 'int') - ->setAllowedValues(self::OPTION_SCALE, Validation::createIsValidCallable( - new Positive(), - )) - ->setRequired(self::OPTION_LABEL) - ->setAllowedTypes(self::OPTION_LABEL, ['string', TranslatableMessage::class]) - ->setRequired(self::OPTION_LOCALIZED_UNIT); - }) - // Make units required. - ->setAllowedValues('units', static fn (array $value) => !empty($value)) - ; - } - - /** - * Get units sorted descending by scale. - */ - private function getUnits(array $options): array - { - $units = $options['units']; - - uasort($units, static fn ($a, $b) => -($a[self::OPTION_SCALE] <=> $b[self::OPTION_SCALE])); - - return $units; - } - - public function getMatchingUnit(?int $value, array $options): array - { - $units = $this->getUnits($options); - foreach ($units as $unit => $info) { - $scale = $info[self::OPTION_SCALE]; - if ($value >= $scale || array_key_last($units) === $unit) { - return [ - self::FIELD_VALUE => null === $value ? null : ($scale > 1 ? $value / $scale : $value), - self::FIELD_UNIT => $unit, - self::OPTION_LOCALIZED_UNIT => $info[self::OPTION_LOCALIZED_UNIT], - ]; - } - } - - throw new \RuntimeException('This should never be called.'); - } - - public function getFormattedValue(int $value, array $options): string - { - $unit = $this->getMatchingUnit($value, $options); - - // @todo There must be a better way to do this! - return sprintf('%s %s', $this->numberFormatter->format($unit['value']), - $unit[self::OPTION_LOCALIZED_UNIT]->trans($this->translator)); + ->setDefault(ValueWithUnitHelper::OPTION_UNITS, []) + ->setDefault(ValueWithUnitHelper::OPTION_SCALE, 0); } } diff --git a/src/Service/ValueWithUnitHelper.php b/src/Service/ValueWithUnitHelper.php new file mode 100644 index 0000000..88eb8be --- /dev/null +++ b/src/Service/ValueWithUnitHelper.php @@ -0,0 +1,118 @@ +numberFormatter = new \NumberFormatter($localeSwitcher->getLocale(), \NumberFormatter::DECIMAL); + } + + private static array $clones = []; + + public function withOptions( + array $options, + ): static { + $options = array_filter($options, static fn ($key) => in_array($key, [ValueWithUnitHelper::OPTION_UNITS, ValueWithUnitHelper::OPTION_SCALE]), ARRAY_FILTER_USE_KEY); + $key = sha1(json_encode($options, JSON_THROW_ON_ERROR)); + if (!isset(self::$clones[$key])) { + $clone = clone $this; + $clone->options = $this->resolveOptions($options); + $clone->numberFormatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $clone->options[self::OPTION_SCALE]); + self::$clones[$key] = $clone; + } + + return self::$clones[$key]; + } + + public function getScale(): int + { + return $this->options[self::OPTION_SCALE] ?? 1; + } + + /** + * Get units sorted descending by scale. + */ + public function getUnits(): array + { + $units = $this->options['units']; + + uasort($units, static fn ($a, $b) => -($a[self::OPTION_SCALE] <=> $b[self::OPTION_SCALE])); + + return $units; + } + + public function getMatchingUnit(?int $value): array + { + $units = $this->getUnits(); + foreach ($units as $unit => $info) { + $scale = $info[self::OPTION_SCALE]; + if ($value >= $scale || array_key_last($units) === $unit) { + return [ + self::FIELD_VALUE => null === $value ? null : ($scale > 1 ? $value / $scale : $value), + self::FIELD_UNIT => $unit, + self::FIELD_LOCALIZED_UNIT => $info[self::OPTION_UNIT_LOCALIZED_UNIT], + ]; + } + } + + throw new \RuntimeException('This should never be called.'); + } + + public function getFormattedValue(int $value): string + { + $unit = $this->getMatchingUnit($value); + + // @todo There must be a better way to do this! + return sprintf('%s %s', $this->numberFormatter->format($unit['value']), + $unit[self::OPTION_UNIT_LOCALIZED_UNIT]->trans($this->translator)); + } + + private function resolveOptions(array $options): array + { + return (new OptionsResolver()) + ->setRequired(self::OPTION_UNITS) + ->setAllowedTypes(self::OPTION_UNITS, 'array') + ->setDefault(self::OPTION_UNITS, function (OptionsResolver $resolver): void { + $resolver + ->setPrototype(true) + ->setDefault(self::OPTION_UNIT_SCALE, 1) + ->setAllowedTypes(self::OPTION_UNIT_SCALE, 'int') + ->setAllowedValues(self::OPTION_UNIT_SCALE, Validation::createIsValidCallable( + new Positive(), + )) + ->setRequired(self::OPTION_UNIT_LABEL) + ->setAllowedTypes(self::OPTION_UNIT_LABEL, ['string', TranslatableMessage::class]) + ->setRequired(self::OPTION_UNIT_LOCALIZED_UNIT); + }) + // Make units required. + ->setAllowedValues(self::OPTION_UNITS, static fn (array $value) => !empty($value)) + ->setRequired(self::OPTION_SCALE) + ->setAllowedTypes(self::OPTION_SCALE, 'int') + ->setDefault(self::OPTION_SCALE, 1) + ->resolve($options); + } +} diff --git a/src/Twig/Runtime/AppExtensionRuntime.php b/src/Twig/Runtime/AppExtensionRuntime.php index f854714..9e32fc5 100644 --- a/src/Twig/Runtime/AppExtensionRuntime.php +++ b/src/Twig/Runtime/AppExtensionRuntime.php @@ -6,6 +6,7 @@ use App\Entity\PointOfInterest; use App\Form\ValueWithUnitType; use App\Service\MediaProcessorInterface; +use App\Service\ValueWithUnitHelper; use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto; use Twig\Extension\RuntimeExtensionInterface; @@ -13,7 +14,7 @@ class AppExtensionRuntime implements RuntimeExtensionInterface { public function __construct( private readonly MediaProcessorInterface $mediaProcessor, - private readonly ValueWithUnitType $valueWithUnitType, + private readonly ValueWithUnitHelper $valueWithUnitHelper, ) { } @@ -44,6 +45,6 @@ public function formatValueWithUnit(FieldDto $field): string throw new \InvalidArgumentException(sprintf("Field's form type must be %s. Found %s.", ValueWithUnitType::class, $field->getFormType() ?? '')); } - return $this->valueWithUnitType->getFormattedValue($field->getValue(), $field->getFormTypeOptions()); + return $this->valueWithUnitHelper->withOptions($field->getFormTypeOptions())->getFormattedValue($field->getValue()); } } From da3e5da8b336f731f7e809f70f8bc9c490eed99e Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Thu, 19 Dec 2024 00:57:24 +0100 Subject: [PATCH 2/3] Added tests --- .env.test | 6 + .github/workflows/pr.yaml | 14 + .gitignore | 5 + Taskfile.yml | 6 + bin/phpunit | 23 ++ composer.json | 6 + composer.lock | 414 ++++++++++++++++++++-- phpunit.xml.dist | 38 ++ src/Service/ValueWithUnitHelper.php | 15 +- symfony.lock | 21 +- tests/Service/ValueWithUnitHelperTest.php | 141 ++++++++ tests/bootstrap.php | 13 + 12 files changed, 660 insertions(+), 42 deletions(-) create mode 100644 .env.test create mode 100755 bin/phpunit create mode 100644 phpunit.xml.dist create mode 100644 tests/Service/ValueWithUnitHelperTest.php create mode 100644 tests/bootstrap.php diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..9e7162f --- /dev/null +++ b/.env.test @@ -0,0 +1,6 @@ +# define your env variables for the test env here +KERNEL_CLASS='App\Kernel' +APP_SECRET='$ecretf0rt3st' +SYMFONY_DEPRECATIONS_HELPER=999999 +PANTHER_APP_ENV=panther +PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 3711f76..da0de28 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -290,3 +290,17 @@ jobs: docker network create frontend docker compose run --rm --user root phpfpm composer install --no-scripts docker compose run --rm --user root phpfpm bin/console biomejs:ci . + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + name: Run tests + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Run tests + run: | + docker network create frontend + docker compose run --rm --user root phpfpm composer install --no-scripts + docker compose run --rm --user root phpfpm bin/phpunit diff --git a/.gitignore b/.gitignore index 71192e8..8ea87c4 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,8 @@ /public/assets/ /assets/vendor/ ###< symfony/asset-mapper ### + +###> symfony/phpunit-bridge ### +.phpunit.result.cache +/phpunit.xml +###< symfony/phpunit-bridge ### diff --git a/Taskfile.yml b/Taskfile.yml index 503c480..0a63711 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -148,3 +148,9 @@ tasks: cmds: - task composer -- update-api-spec silent: true + + test: + desc: "Run tests" + cmds: + - task compose -- exec phpfpm bin/phpunit {{.CLI_ARGS}} + silent: true diff --git a/bin/phpunit b/bin/phpunit new file mode 100755 index 0000000..692bacc --- /dev/null +++ b/bin/phpunit @@ -0,0 +1,23 @@ +#!/usr/bin/env php += 80000) { + require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit'; + } else { + define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php'); + require PHPUNIT_COMPOSER_INSTALL; + PHPUnit\TextUI\Command::main(); + } +} else { + if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) { + echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n"; + exit(1); + } + + require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php'; +} diff --git a/composer.json b/composer.json index b1dc29c..b83f4ba 100644 --- a/composer.json +++ b/composer.json @@ -52,8 +52,11 @@ "friendsofphp/php-cs-fixer": "^3.20", "hautelook/alice-bundle": "^2.12", "kocal/biome-js-bundle": "^1.3", + "symfony/browser-kit": "7.2.*", + "symfony/css-selector": "7.2.*", "symfony/debug-bundle": "~7.2.0", "symfony/maker-bundle": "*", + "symfony/phpunit-bridge": "^7.2", "symfony/stopwatch": "~7.2.0", "symfony/web-profiler-bundle": "~7.2.0", "vincentlanglet/twig-cs-fixer": "^3.3", @@ -84,12 +87,15 @@ } }, "config": { + "//": "See https://github.com/api-platform/api-platform/issues/2437#issuecomment-1540620564 for details on why prepend-autoloader is set", "allow-plugins": { "ergebnis/composer-normalize": true, "php-http/discovery": true, "symfony/flex": true, "symfony/runtime": true }, + "optimize-autoloader": true, + "prepend-autoloader": false, "sort-packages": true }, "extra": { diff --git a/composer.lock b/composer.lock index a5e2c17..7ddd74f 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": "dacda89ecd6218395778c1a4327c84ac", + "content-hash": "361ae6436338b139c17ec6223c8b1189", "packages": [ { "name": "amphp/amp", @@ -168,16 +168,16 @@ }, { "name": "api-platform/core", - "version": "v3.4.8", + "version": "v3.4.9", "source": { "type": "git", "url": "https://github.com/api-platform/core.git", - "reference": "985a9a0408cfc31721adbe43c6ae38d9c3a8c88f" + "reference": "535bbf57a7fc00ba0f8d05261050d90b3cbad987" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/core/zipball/985a9a0408cfc31721adbe43c6ae38d9c3a8c88f", - "reference": "985a9a0408cfc31721adbe43c6ae38d9c3a8c88f", + "url": "https://api.github.com/repos/api-platform/core/zipball/535bbf57a7fc00ba0f8d05261050d90b3cbad987", + "reference": "535bbf57a7fc00ba0f8d05261050d90b3cbad987", "shasum": "" }, "require": { @@ -383,9 +383,9 @@ ], "support": { "issues": "https://github.com/api-platform/core/issues", - "source": "https://github.com/api-platform/core/tree/v3.4.8" + "source": "https://github.com/api-platform/core/tree/v3.4.9" }, - "time": "2024-12-06T11:11:33+00:00" + "time": "2024-12-13T14:29:05+00:00" }, { "name": "behat/transliterator", @@ -1849,16 +1849,16 @@ }, { "name": "doctrine/orm", - "version": "2.20.0", + "version": "2.20.1", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "8ed6c2234aba019f9737a6bcc9516438e62da27c" + "reference": "e3cabade99ebccc6ba078884c1c5f250866a494e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/8ed6c2234aba019f9737a6bcc9516438e62da27c", - "reference": "8ed6c2234aba019f9737a6bcc9516438e62da27c", + "url": "https://api.github.com/repos/doctrine/orm/zipball/e3cabade99ebccc6ba078884c1c5f250866a494e", + "reference": "e3cabade99ebccc6ba078884c1c5f250866a494e", "shasum": "" }, "require": { @@ -1888,15 +1888,14 @@ "doctrine/coding-standard": "^9.0.2 || ^12.0", "phpbench/phpbench": "^0.16.10 || ^1.0", "phpstan/extension-installer": "~1.1.0 || ^1.4", - "phpstan/phpstan": "~1.4.10 || 1.12.6", - "phpstan/phpstan-deprecation-rules": "^1", + "phpstan/phpstan": "~1.4.10 || 2.0.3", + "phpstan/phpstan-deprecation-rules": "^1 || ^2", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6", "psr/log": "^1 || ^2 || ^3", "squizlabs/php_codesniffer": "3.7.2", "symfony/cache": "^4.4 || ^5.4 || ^6.4 || ^7.0", "symfony/var-exporter": "^4.4 || ^5.4 || ^6.2 || ^7.0", - "symfony/yaml": "^3.4 || ^4.0 || ^5.0 || ^6.0 || ^7.0", - "vimeo/psalm": "4.30.0 || 5.24.0" + "symfony/yaml": "^3.4 || ^4.0 || ^5.0 || ^6.0 || ^7.0" }, "suggest": { "ext-dom": "Provides support for XSD validation for XML mapping files", @@ -1946,9 +1945,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/2.20.0" + "source": "https://github.com/doctrine/orm/tree/2.20.1" }, - "time": "2024-10-11T11:47:24+00:00" + "time": "2024-12-19T06:48:36+00:00" }, { "name": "doctrine/persistence", @@ -2651,9 +2650,9 @@ "type": "symfony-bundle", "extra": { "symfony": { - "allow-contrib": false, + "docker": false, "require": "~6.4|~7.0", - "docker": false + "allow-contrib": false } }, "autoload": { @@ -3860,16 +3859,16 @@ }, { "name": "spatie/array-to-xml", - "version": "3.3.0", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/spatie/array-to-xml.git", - "reference": "f56b220fe2db1ade4c88098d83413ebdfc3bf876" + "reference": "7dcfc67d60b0272926dabad1ec01f6b8a5fb5e67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/f56b220fe2db1ade4c88098d83413ebdfc3bf876", - "reference": "f56b220fe2db1ade4c88098d83413ebdfc3bf876", + "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/7dcfc67d60b0272926dabad1ec01f6b8a5fb5e67", + "reference": "7dcfc67d60b0272926dabad1ec01f6b8a5fb5e67", "shasum": "" }, "require": { @@ -3912,7 +3911,7 @@ "xml" ], "support": { - "source": "https://github.com/spatie/array-to-xml/tree/3.3.0" + "source": "https://github.com/spatie/array-to-xml/tree/3.4.0" }, "funding": [ { @@ -3924,7 +3923,7 @@ "type": "github" } ], - "time": "2024-05-01T10:20:27+00:00" + "time": "2024-12-16T12:45:15+00:00" }, { "name": "stof/doctrine-extensions-bundle", @@ -6654,8 +6653,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -10939,6 +10938,73 @@ }, "time": "2024-11-28T04:54:44+00:00" }, + { + "name": "masterminds/html5", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.9.0" + }, + "time": "2024-03-31T07:05:07+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.12.1", @@ -11843,6 +11909,139 @@ ], "time": "2024-07-03T05:10:34+00:00" }, + { + "name": "symfony/browser-kit", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "8d64d17e198082f8f198d023a6b634e7b5fdda94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/8d64d17e198082f8f198d023a6b634e7b5fdda94", + "reference": "8d64d17e198082f8f198d023a6b634e7b5fdda94", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/dom-crawler": "^6.4|^7.0" + }, + "require-dev": { + "symfony/css-selector": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/tree/v7.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-10-25T15:15:23+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, { "name": "symfony/debug-bundle", "version": "v7.2.0", @@ -11917,6 +12116,73 @@ ], "time": "2024-09-25T14:21:43+00:00" }, + { + "name": "symfony/dom-crawler", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "b176e1f1f550ef44c94eb971bf92488de08f7c6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/b176e1f1f550ef44c94eb971bf92488de08f7c6b", + "reference": "b176e1f1f550ef44c94eb971bf92488de08f7c6b", + "shasum": "" + }, + "require": { + "masterminds/html5": "^2.6", + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0" + }, + "require-dev": { + "symfony/css-selector": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v7.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-11-13T16:15:23+00:00" + }, { "name": "symfony/maker-bundle", "version": "v1.61.0", @@ -12009,6 +12275,88 @@ ], "time": "2024-08-29T22:50:23+00:00" }, + { + "name": "symfony/phpunit-bridge", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/phpunit-bridge.git", + "reference": "2bbde92ab25a0e2c88160857af7be9db5da0d145" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/2bbde92ab25a0e2c88160857af7be9db5da0d145", + "reference": "2bbde92ab25a0e2c88160857af7be9db5da0d145", + "shasum": "" + }, + "require": { + "php": ">=7.2.5" + }, + "conflict": { + "phpunit/phpunit": "<7.5|9.1.2" + }, + "require-dev": { + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/error-handler": "^5.4|^6.4|^7.0", + "symfony/polyfill-php81": "^1.27" + }, + "bin": [ + "bin/simple-phpunit" + ], + "type": "symfony-bridge", + "extra": { + "thanks": { + "url": "https://github.com/sebastianbergmann/phpunit", + "name": "phpunit/phpunit" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Bridge\\PhpUnit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/bin/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides utilities for PHPUnit, especially user deprecation notices management", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/phpunit-bridge/tree/v7.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-11-13T16:15:23+00:00" + }, { "name": "symfony/process", "version": "v7.2.0", @@ -12256,16 +12604,16 @@ }, { "name": "vincentlanglet/twig-cs-fixer", - "version": "3.4.0", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/VincentLanglet/Twig-CS-Fixer.git", - "reference": "a193004602d7a9b1c17408eb8b8a1632532a28a7" + "reference": "f81af33e48c384be7e0e3689f02e6e712fa68beb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/a193004602d7a9b1c17408eb8b8a1632532a28a7", - "reference": "a193004602d7a9b1c17408eb8b8a1632532a28a7", + "url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/f81af33e48c384be7e0e3689f02e6e712fa68beb", + "reference": "f81af33e48c384be7e0e3689f02e6e712fa68beb", "shasum": "" }, "require": { @@ -12324,7 +12672,7 @@ "homepage": "https://github.com/VincentLanglet/Twig-CS-Fixer", "support": { "issues": "https://github.com/VincentLanglet/Twig-CS-Fixer/issues", - "source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/3.4.0" + "source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/3.5.0" }, "funding": [ { @@ -12332,7 +12680,7 @@ "type": "github" } ], - "time": "2024-12-04T18:37:59+00:00" + "time": "2024-12-13T16:55:11+00:00" }, { "name": "weirdan/doctrine-psalm-plugin", diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..c76a655 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + tests + + + + + + src + + + + + + + + + + diff --git a/src/Service/ValueWithUnitHelper.php b/src/Service/ValueWithUnitHelper.php index 88eb8be..fabdd45 100644 --- a/src/Service/ValueWithUnitHelper.php +++ b/src/Service/ValueWithUnitHelper.php @@ -19,7 +19,6 @@ final class ValueWithUnitHelper public const string FIELD_VALUE = 'value'; public const string FIELD_UNIT = 'unit'; - public const string FIELD_LOCALIZED_UNIT = self::OPTION_UNIT_LOCALIZED_UNIT; private \NumberFormatter $numberFormatter; private array $options; @@ -71,10 +70,9 @@ public function getMatchingUnit(?int $value): array foreach ($units as $unit => $info) { $scale = $info[self::OPTION_SCALE]; if ($value >= $scale || array_key_last($units) === $unit) { - return [ + return $info + [ self::FIELD_VALUE => null === $value ? null : ($scale > 1 ? $value / $scale : $value), self::FIELD_UNIT => $unit, - self::FIELD_LOCALIZED_UNIT => $info[self::OPTION_UNIT_LOCALIZED_UNIT], ]; } } @@ -86,9 +84,13 @@ public function getFormattedValue(int $value): string { $unit = $this->getMatchingUnit($value); + $localizedUnit = $unit[self::OPTION_UNIT_LOCALIZED_UNIT] ?? $unit[self::FIELD_UNIT]; + if ($localizedUnit instanceof TranslatableMessage) { + $localizedUnit = $localizedUnit->trans($this->translator); + } + // @todo There must be a better way to do this! - return sprintf('%s %s', $this->numberFormatter->format($unit['value']), - $unit[self::OPTION_UNIT_LOCALIZED_UNIT]->trans($this->translator)); + return sprintf('%s %s', $this->numberFormatter->format($unit['value']), $localizedUnit); } private function resolveOptions(array $options): array @@ -106,7 +108,8 @@ private function resolveOptions(array $options): array )) ->setRequired(self::OPTION_UNIT_LABEL) ->setAllowedTypes(self::OPTION_UNIT_LABEL, ['string', TranslatableMessage::class]) - ->setRequired(self::OPTION_UNIT_LOCALIZED_UNIT); + ->setDefault(self::OPTION_UNIT_LOCALIZED_UNIT, null) + ->setAllowedTypes(self::OPTION_UNIT_LOCALIZED_UNIT, ['null', 'string', TranslatableMessage::class]); }) // Make units required. ->setAllowedValues(self::OPTION_UNITS, static fn (array $value) => !empty($value)) diff --git a/symfony.lock b/symfony.lock index 4cf24c4..531bd43 100644 --- a/symfony.lock +++ b/symfony.lock @@ -82,7 +82,7 @@ ] }, "hautelook/alice-bundle": { - "version": "2.12", + "version": "2.13", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", @@ -111,7 +111,7 @@ ] }, "nelmio/alice": { - "version": "3.12", + "version": "3.13", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", @@ -261,6 +261,21 @@ "config/packages/monolog.yaml" ] }, + "symfony/phpunit-bridge": { + "version": "7.2", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.3", + "ref": "a411a0480041243d97382cac7984f7dce7813c08" + }, + "files": [ + ".env.test", + "bin/phpunit", + "phpunit.xml.dist", + "tests/bootstrap.php" + ] + }, "symfony/routing": { "version": "6.3", "recipe": { @@ -374,7 +389,7 @@ ] }, "theofidry/alice-data-fixtures": { - "version": "1.6", + "version": "1.8", "recipe": { "repo": "github.com/symfony/recipes", "branch": "main", diff --git a/tests/Service/ValueWithUnitHelperTest.php b/tests/Service/ValueWithUnitHelperTest.php new file mode 100644 index 0000000..c35571b --- /dev/null +++ b/tests/Service/ValueWithUnitHelperTest.php @@ -0,0 +1,141 @@ +helper = $container->get(ValueWithUnitHelper::class); + } + + public function testGetScale(): void + { + $helper = $this->helper->withOptions([ + ValueWithUnitHelper::OPTION_UNITS => ['test' => [ValueWithUnitHelper::OPTION_UNIT_LABEL => 'test']], + ValueWithUnitHelper::OPTION_SCALE => 2, + ]); + $expected = 2; + $actual = $helper->getScale(); + + $this->assertEquals($expected, $actual); + } + + public function testGetUnits(): void + { + $helper = $this->helper->withOptions([ + ValueWithUnitHelper::OPTION_UNITS => ['test' => [ValueWithUnitHelper::OPTION_UNIT_LABEL => 'test']], + ]); + $expected = [ + 'test' => [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'test', + ValueWithUnitHelper::OPTION_UNIT_SCALE => 1, + ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, + ], + ]; + $actual = $helper->getUnits(); + + $this->assertEquals($expected, $actual); + } + + /** + * @dataProvider getMatchingUnitProvider + */ + public function testGetMatchingUnit(array $options, int $value, array $expected): void + { + $helper = $this->helper->withOptions($options); + $actual = $helper->getMatchingUnit($value); + + $this->assertEquals($expected, $actual); + } + + public static function getMatchingUnitProvider(): iterable + { + $options = [ + ValueWithUnitHelper::OPTION_UNITS => ['test' => [ValueWithUnitHelper::OPTION_UNIT_LABEL => 'test']], + ]; + + yield [ + $options, + 87, + [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'test', + ValueWithUnitHelper::OPTION_UNIT_SCALE => 1, + ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, + ValueWithUnitHelper::FIELD_VALUE => 87, + ValueWithUnitHelper::FIELD_UNIT => 'test', + ], + ]; + + $options = [ + ValueWithUnitHelper::OPTION_UNITS => [ + 'm' => [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'meter', + ValueWithUnitHelper::OPTION_SCALE => 1, + ], + 'km' => [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'kilometer', + ValueWithUnitHelper::OPTION_SCALE => 1000, + ], + ], + ]; + + yield [ + $options, + 87, + [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'meter', + ValueWithUnitHelper::OPTION_UNIT_SCALE => 1, + ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, + ValueWithUnitHelper::FIELD_VALUE => 87, + ValueWithUnitHelper::FIELD_UNIT => 'm', + ], + ]; + + yield [ + $options, + 1234, + [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'kilometer', + ValueWithUnitHelper::OPTION_UNIT_SCALE => 1000, + ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, + ValueWithUnitHelper::FIELD_VALUE => 1, + ValueWithUnitHelper::FIELD_UNIT => 'km', + ], + ]; + + $options[ValueWithUnitHelper::OPTION_SCALE] = 1; + yield [ + $options, + 87, + [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'meter', + ValueWithUnitHelper::OPTION_UNIT_SCALE => 1, + ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, + ValueWithUnitHelper::FIELD_VALUE => 87, + ValueWithUnitHelper::FIELD_UNIT => 'm', + ], + ]; + + yield [ + $options, + 1234, + [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'kilometer', + ValueWithUnitHelper::OPTION_UNIT_SCALE => 1000, + ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, + ValueWithUnitHelper::FIELD_VALUE => 1.2, + ValueWithUnitHelper::FIELD_UNIT => 'km', + ], + ]; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..47a5855 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,13 @@ +bootEnv(dirname(__DIR__).'/.env'); +} + +if ($_SERVER['APP_DEBUG']) { + umask(0000); +} From 548c755540baa09c0008c2e1ae4d5ca1dbad577c Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Thu, 19 Dec 2024 23:14:59 +0100 Subject: [PATCH 3/3] Cleaned up and made stuff work --- .../Admin/PointOfInterestCrudController.php | 2 +- src/Controller/Admin/RouteCrudController.php | 9 +- src/Form/ValueWithUnitType.php | 28 +-- src/Service/ValueWithUnitHelper.php | 33 +-- tests/Service/ValueWithUnitHelperTest.php | 235 +++++++++++++++++- 5 files changed, 260 insertions(+), 47 deletions(-) diff --git a/src/Controller/Admin/PointOfInterestCrudController.php b/src/Controller/Admin/PointOfInterestCrudController.php index 9c94c29..fb0a1ab 100644 --- a/src/Controller/Admin/PointOfInterestCrudController.php +++ b/src/Controller/Admin/PointOfInterestCrudController.php @@ -97,7 +97,7 @@ public function configureFields(string $pageName): iterable ->setUnits([ 'm' => [ ValueWithUnitHelper::OPTION_UNIT_LABEL => new TranslatableMessage('meter', [], 'admin'), - ValueWithUnitHelper::OPTION_SCALE => 1, + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 1, ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => new TranslatableMessage('unit.m', [], 'admin'), ], ]) diff --git a/src/Controller/Admin/RouteCrudController.php b/src/Controller/Admin/RouteCrudController.php index 30d3163..acba9e4 100644 --- a/src/Controller/Admin/RouteCrudController.php +++ b/src/Controller/Admin/RouteCrudController.php @@ -84,12 +84,12 @@ public function configureFields(string $pageName): iterable ->setUnits([ 'km' => [ ValueWithUnitHelper::OPTION_UNIT_LABEL => new TranslatableMessage('kilometer', [], 'admin'), - ValueWithUnitHelper::OPTION_SCALE => 1000, + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 1000, ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => new TranslatableMessage('unit.km', [], 'admin'), ], 'm' => [ ValueWithUnitHelper::OPTION_UNIT_LABEL => new TranslatableMessage('meter', [], 'admin'), - ValueWithUnitHelper::OPTION_SCALE => 1, + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 1, ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => new TranslatableMessage('unit.m', [], 'admin'), ], ]) @@ -97,16 +97,15 @@ public function configureFields(string $pageName): iterable ->setHelp(new TranslatableMessage('The total distance of the route with all points of interests included.', [], 'admin')); yield ValueWithUnitField::new('totalDuration', new TranslatableMessage('Total duration', [], 'admin')) - ->setScale(1) ->setUnits([ 'hour' => [ ValueWithUnitHelper::OPTION_UNIT_LABEL => new TranslatableMessage('hours', [], 'admin'), - ValueWithUnitHelper::OPTION_SCALE => 60 * 60, + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 60 * 60, ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => new TranslatableMessage('unit.hour', [], 'admin'), ], 'minute' => [ ValueWithUnitHelper::OPTION_UNIT_LABEL => new TranslatableMessage('minutes', [], 'admin'), - ValueWithUnitHelper::OPTION_SCALE => 60, + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 60, ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => new TranslatableMessage('unit.minute', [], 'admin'), ], ]) diff --git a/src/Form/ValueWithUnitType.php b/src/Form/ValueWithUnitType.php index 9c61c3e..5ad9e54 100644 --- a/src/Form/ValueWithUnitType.php +++ b/src/Form/ValueWithUnitType.php @@ -27,49 +27,49 @@ public function __construct( public function buildForm(FormBuilderInterface $builder, array $options): void { - $this->helper = $this->helper->withOptions($options); + $helper = $this->helper->withOptions($options); - $units = array_keys($options['units']); - $unitChoices = array_combine($units, $units); + $units = $helper->getUnits(); + $unitKeys = array_keys($units); + $unitChoices = array_combine($unitKeys, $unitKeys); $builder ->add(self::FIELD_VALUE, NumberType::class, [ - 'scale' => $this->helper->getScale(), + 'scale' => $helper->getScale(), ]) ->add(self::FIELD_UNIT, ChoiceType::class, [ 'choices' => $unitChoices, - 'choice_label' => function ($choice, string $key, mixed $value) use ($options - ): TranslatableMessage|string { - return $options['units'][$key]['label'] ?? $key; + 'choice_label' => function ($choice, string $key, mixed $value) use ($units): TranslatableMessage|string { + return $units[$key][ValueWithUnitHelper::OPTION_UNIT_LABEL] ?? $key; }, ]); $builder ->addModelTransformer(new CallbackTransformer( - $this->transform(...), - $this->reverseTransform(...), + fn (mixed $value) => $this->transform($value, $helper), + fn (mixed $value) => $this->reverseTransform($value, $helper), )); } - public function transform(?int $value): array + public function transform(?int $value, ValueWithUnitHelper $helper): array { try { - return $this->helper->getMatchingUnit($value); + return $helper->getMatchingUnit($value); } catch (\Exception) { throw new TransformationFailedException(invalidMessage: 'Error transforming value: {value}.', invalidMessageParameters: ['value' => $value, /* @todo Make this work! */ 'translation_domain' => 'admin']); } } - public function reverseTransform(array $values): int + public function reverseTransform(array $values, ValueWithUnitHelper $helper): int { [self::FIELD_VALUE => $value, self::FIELD_UNIT => $unit] = $values; - $units = $this->helper->getUnits(); + $units = $helper->getUnits(); $info = $units[$unit] ?? null; if (null === $info) { throw new TransformationFailedException(invalidMessage: 'Invalid unit: {unit}.', invalidMessageParameters: ['unit' => $unit, /* @todo Make this work! */ 'translation_domain' => 'admin']); } - return $value * $this->helper->getScale(); + return $value * $info[ValueWithUnitHelper::OPTION_UNIT_FACTOR]; } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Service/ValueWithUnitHelper.php b/src/Service/ValueWithUnitHelper.php index fabdd45..7c32ba2 100644 --- a/src/Service/ValueWithUnitHelper.php +++ b/src/Service/ValueWithUnitHelper.php @@ -3,7 +3,6 @@ namespace App\Service; use Symfony\Component\OptionsResolver\OptionsResolver; -use Symfony\Component\Translation\LocaleSwitcher; use Symfony\Component\Translation\TranslatableMessage; use Symfony\Component\Validator\Constraints\Positive; use Symfony\Component\Validator\Validation; @@ -14,20 +13,18 @@ final class ValueWithUnitHelper public const string OPTION_UNITS = 'units'; public const string OPTION_SCALE = 'scale'; public const string OPTION_UNIT_LABEL = 'label'; - public const string OPTION_UNIT_SCALE = 'scale'; + public const string OPTION_UNIT_FACTOR = 'factor'; public const string OPTION_UNIT_LOCALIZED_UNIT = 'localized_unit'; public const string FIELD_VALUE = 'value'; public const string FIELD_UNIT = 'unit'; - private \NumberFormatter $numberFormatter; private array $options; + private \NumberFormatter $numberFormatter; public function __construct( private readonly TranslatorInterface $translator, - LocaleSwitcher $localeSwitcher, ) { - $this->numberFormatter = new \NumberFormatter($localeSwitcher->getLocale(), \NumberFormatter::DECIMAL); } private static array $clones = []; @@ -40,7 +37,9 @@ public function withOptions( if (!isset(self::$clones[$key])) { $clone = clone $this; $clone->options = $this->resolveOptions($options); + $clone->numberFormatter = new \NumberFormatter($this->translator->getLocale(), \NumberFormatter::DECIMAL); $clone->numberFormatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $clone->options[self::OPTION_SCALE]); + $clone->numberFormatter->setSymbol(\NumberFormatter::GROUPING_SEPARATOR_SYMBOL, ''); self::$clones[$key] = $clone; } @@ -53,13 +52,13 @@ public function getScale(): int } /** - * Get units sorted descending by scale. + * Get units sorted descending by factor. */ public function getUnits(): array { $units = $this->options['units']; - uasort($units, static fn ($a, $b) => -($a[self::OPTION_SCALE] <=> $b[self::OPTION_SCALE])); + uasort($units, static fn ($a, $b) => -($a[self::OPTION_UNIT_FACTOR] <=> $b[self::OPTION_UNIT_FACTOR])); return $units; } @@ -68,10 +67,16 @@ public function getMatchingUnit(?int $value): array { $units = $this->getUnits(); foreach ($units as $unit => $info) { - $scale = $info[self::OPTION_SCALE]; - if ($value >= $scale || array_key_last($units) === $unit) { + // Make sure that factor is positive. + $factor = max(1, $info[self::OPTION_UNIT_FACTOR]); + + // $isMatch = 0 === $value % $factor; + $truncatedValue = (float) number_format($value / $factor, $this->getScale(), '.', ''); + $isMatch = (float) ($value / $factor) === $truncatedValue; + + if (($value >= $factor && $isMatch) || array_key_last($units) === $unit) { return $info + [ - self::FIELD_VALUE => null === $value ? null : ($scale > 1 ? $value / $scale : $value), + self::FIELD_VALUE => null === $value ? null : $truncatedValue, self::FIELD_UNIT => $unit, ]; } @@ -101,9 +106,9 @@ private function resolveOptions(array $options): array ->setDefault(self::OPTION_UNITS, function (OptionsResolver $resolver): void { $resolver ->setPrototype(true) - ->setDefault(self::OPTION_UNIT_SCALE, 1) - ->setAllowedTypes(self::OPTION_UNIT_SCALE, 'int') - ->setAllowedValues(self::OPTION_UNIT_SCALE, Validation::createIsValidCallable( + ->setDefault(self::OPTION_UNIT_FACTOR, 1) + ->setAllowedTypes(self::OPTION_UNIT_FACTOR, 'int') + ->setAllowedValues(self::OPTION_UNIT_FACTOR, Validation::createIsValidCallable( new Positive(), )) ->setRequired(self::OPTION_UNIT_LABEL) @@ -115,7 +120,7 @@ private function resolveOptions(array $options): array ->setAllowedValues(self::OPTION_UNITS, static fn (array $value) => !empty($value)) ->setRequired(self::OPTION_SCALE) ->setAllowedTypes(self::OPTION_SCALE, 'int') - ->setDefault(self::OPTION_SCALE, 1) + ->setDefault(self::OPTION_SCALE, 0) ->resolve($options); } } diff --git a/tests/Service/ValueWithUnitHelperTest.php b/tests/Service/ValueWithUnitHelperTest.php index c35571b..10a0626 100644 --- a/tests/Service/ValueWithUnitHelperTest.php +++ b/tests/Service/ValueWithUnitHelperTest.php @@ -4,6 +4,7 @@ use App\Service\ValueWithUnitHelper; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Translation\LocaleSwitcher; final class ValueWithUnitHelperTest extends KernelTestCase { @@ -16,6 +17,7 @@ protected function setUp(): void $container = static::getContainer(); $this->helper = $container->get(ValueWithUnitHelper::class); + $container->get(LocaleSwitcher::class)->setLocale('en'); } public function testGetScale(): void @@ -38,7 +40,7 @@ public function testGetUnits(): void $expected = [ 'test' => [ ValueWithUnitHelper::OPTION_UNIT_LABEL => 'test', - ValueWithUnitHelper::OPTION_UNIT_SCALE => 1, + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 1, ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, ], ]; @@ -61,7 +63,11 @@ public function testGetMatchingUnit(array $options, int $value, array $expected) public static function getMatchingUnitProvider(): iterable { $options = [ - ValueWithUnitHelper::OPTION_UNITS => ['test' => [ValueWithUnitHelper::OPTION_UNIT_LABEL => 'test']], + ValueWithUnitHelper::OPTION_UNITS => [ + 'test' => [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'test', + ], + ], ]; yield [ @@ -69,7 +75,7 @@ public static function getMatchingUnitProvider(): iterable 87, [ ValueWithUnitHelper::OPTION_UNIT_LABEL => 'test', - ValueWithUnitHelper::OPTION_UNIT_SCALE => 1, + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 1, ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, ValueWithUnitHelper::FIELD_VALUE => 87, ValueWithUnitHelper::FIELD_UNIT => 'test', @@ -80,11 +86,11 @@ public static function getMatchingUnitProvider(): iterable ValueWithUnitHelper::OPTION_UNITS => [ 'm' => [ ValueWithUnitHelper::OPTION_UNIT_LABEL => 'meter', - ValueWithUnitHelper::OPTION_SCALE => 1, + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 1, ], 'km' => [ ValueWithUnitHelper::OPTION_UNIT_LABEL => 'kilometer', - ValueWithUnitHelper::OPTION_SCALE => 1000, + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 1000, ], ], ]; @@ -94,7 +100,7 @@ public static function getMatchingUnitProvider(): iterable 87, [ ValueWithUnitHelper::OPTION_UNIT_LABEL => 'meter', - ValueWithUnitHelper::OPTION_UNIT_SCALE => 1, + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 1, ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, ValueWithUnitHelper::FIELD_VALUE => 87, ValueWithUnitHelper::FIELD_UNIT => 'm', @@ -105,11 +111,11 @@ public static function getMatchingUnitProvider(): iterable $options, 1234, [ - ValueWithUnitHelper::OPTION_UNIT_LABEL => 'kilometer', - ValueWithUnitHelper::OPTION_UNIT_SCALE => 1000, + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'meter', + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 1, ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, - ValueWithUnitHelper::FIELD_VALUE => 1, - ValueWithUnitHelper::FIELD_UNIT => 'km', + ValueWithUnitHelper::FIELD_VALUE => 1234, + ValueWithUnitHelper::FIELD_UNIT => 'm', ], ]; @@ -119,7 +125,7 @@ public static function getMatchingUnitProvider(): iterable 87, [ ValueWithUnitHelper::OPTION_UNIT_LABEL => 'meter', - ValueWithUnitHelper::OPTION_UNIT_SCALE => 1, + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 1, ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, ValueWithUnitHelper::FIELD_VALUE => 87, ValueWithUnitHelper::FIELD_UNIT => 'm', @@ -129,13 +135,216 @@ public static function getMatchingUnitProvider(): iterable yield [ $options, 1234, + [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'meter', + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 1, + ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, + ValueWithUnitHelper::FIELD_VALUE => 1234, + ValueWithUnitHelper::FIELD_UNIT => 'm', + ], + ]; + + yield [ + $options, + 2000, [ ValueWithUnitHelper::OPTION_UNIT_LABEL => 'kilometer', - ValueWithUnitHelper::OPTION_UNIT_SCALE => 1000, + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 1000, ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, - ValueWithUnitHelper::FIELD_VALUE => 1.2, + ValueWithUnitHelper::FIELD_VALUE => 2.0, ValueWithUnitHelper::FIELD_UNIT => 'km', ], ]; + + yield [ + $options, + 2100, + [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'kilometer', + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 1000, + ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, + ValueWithUnitHelper::FIELD_VALUE => 2.1, + ValueWithUnitHelper::FIELD_UNIT => 'km', + ], + ]; + + $options = [ + ValueWithUnitHelper::OPTION_UNITS => [ + 'hour' => [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'hours', + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 60 * 60, + ], + 'minute' => [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'minutes', + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 60, + ], + ], + ]; + + yield [ + $options, + 9000, + [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'minutes', + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 60, + ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, + ValueWithUnitHelper::FIELD_VALUE => 150, + ValueWithUnitHelper::FIELD_UNIT => 'minute', + ], + ]; + + yield [ + $options, + 10800, + [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'hours', + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 60 * 60, + ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, + ValueWithUnitHelper::FIELD_VALUE => 3, + ValueWithUnitHelper::FIELD_UNIT => 'hour', + ], + ]; + + $options[ValueWithUnitHelper::OPTION_SCALE] = 1; + + yield [ + $options, + 9000, + [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'hours', + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 60 * 60, + ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, + ValueWithUnitHelper::FIELD_VALUE => 2.5, + ValueWithUnitHelper::FIELD_UNIT => 'hour', + ], + ]; + + yield [ + $options, + 10800, + [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'hours', + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 60 * 60, + ValueWithUnitHelper::OPTION_UNIT_LOCALIZED_UNIT => null, + ValueWithUnitHelper::FIELD_VALUE => 3, + ValueWithUnitHelper::FIELD_UNIT => 'hour', + ], + ]; + } + + /** + * @dataProvider getFormattedValueProvider + */ + public function testGetFormattedValue(array $options, int $value, string $expected): void + { + $helper = $this->helper->withOptions($options); + $actual = $helper->getFormattedValue($value); + + $this->assertEquals($expected, $actual); + } + + public static function getFormattedValueProvider(): iterable + { + $options = [ + ValueWithUnitHelper::OPTION_UNITS => [ + 'test' => [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'test', + ], + ], + ]; + + yield [ + $options, + 87, + '87 test', + ]; + + $options = [ + ValueWithUnitHelper::OPTION_UNITS => [ + 'm' => [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'meter', + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 1, + ], + 'km' => [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'kilometer', + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 1000, + ], + ], + ]; + + yield [ + $options, + 87, + '87 m', + ]; + + yield [ + $options, + 1234, + '1234 m', + ]; + + $options[ValueWithUnitHelper::OPTION_SCALE] = 1; + yield [ + $options, + 87, + '87.0 m', + ]; + + yield [ + $options, + 1234, + '1234.0 m', + ]; + + yield [ + $options, + 2000, + '2.0 km', + ]; + + yield [ + $options, + 2100, + '2.1 km', + ]; + + $options = [ + ValueWithUnitHelper::OPTION_UNITS => [ + 'hour' => [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'hours', + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 60 * 60, + ], + 'minute' => [ + ValueWithUnitHelper::OPTION_UNIT_LABEL => 'minutes', + ValueWithUnitHelper::OPTION_UNIT_FACTOR => 60, + ], + ], + ]; + + yield [ + $options, + 9000, + '150 minute', + ]; + + yield [ + $options, + 10800, + '3 hour', + ]; + + $options[ValueWithUnitHelper::OPTION_SCALE] = 1; + yield [ + $options, + 9000, + '2.5 hour', + ]; + + yield [ + $options, + 10800, + '3.0 hour', + ]; } }