From 60a66561413fc0d366cae9acf57ac553bb1a919d Mon Sep 17 00:00:00 2001 From: Romain Canon Date: Wed, 18 May 2022 23:15:08 +0200 Subject: [PATCH] feat!: improve message customization with formatters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The way messages can be customized has been totally revisited, requiring several breaking changes. All existing error messages have been rewritten to better fit the actual meaning of the error. The content of a message can be changed to fit custom use cases; it can contain placeholders that will be replaced with useful information. The placeholders below are always available; even more may be used depending on the original message. - `{message_code}` — the code of the message - `{node_name}` — name of the node to which the message is bound - `{node_path}` — path of the node to which the message is bound - `{node_type}` — type of the node to which the message is bound - `{original_value}` — the source value that was given to the node - `{original_message}` — the original message before being customized ```php try { (new \CuyZ\Valinor\MapperBuilder()) ->mapper() ->map(SomeClass::class, [/* … */]); } catch (\CuyZ\Valinor\Mapper\MappingError $error) { $messages = new MessagesFlattener($error->node()); foreach ($messages as $message) { if ($message->code() === 'some_code') { $message = $message->withBody('new / {original_message}'); } echo $message; } } ``` The messages are formatted using the ICU library, enabling the placeholders to use advanced syntax to perform proper translations, for instance currency support. ```php try { (new MapperBuilder())->mapper()->map('int<0, 100>', 1337); } catch (\CuyZ\Valinor\Mapper\MappingError $error) { $message = $error->node()->messages()[0]; if (is_numeric($message->value())) { $message = $message->withBody( 'Invalid amount {original_value, number, currency}' ); } // Invalid amount: $1,337.00 echo $message->withLocale('en_US'); // Invalid amount: £1,337.00 echo $message->withLocale('en_GB'); // Invalid amount: 1 337,00 € echo $message->withLocale('fr_FR'); } ``` If the `intl` extension is not installed, a shim will be available to replace the placeholders, but it won't handle advanced syntax as described above. --- The new formatter `TranslationMessageFormatter` can be used to translate the content of messages. The library provides a list of all messages that can be returned; this list can be filled or modified with custom translations. ```php TranslationMessageFormatter::default() // Create/override a single entry… ->withTranslation( 'fr', 'some custom message', 'un message personnalisé' ) // …or several entries. ->withTranslations([ 'some custom message' => [ 'en' => 'Some custom message', 'fr' => 'Un message personnalisé', 'es' => 'Un mensaje personalizado', ], 'some other message' => [ // … ], ]) ->format($message); ``` It is possible to join several formatters into one formatter by using the `AggregateMessageFormatter`. This instance can then easily be injected in a service that will handle messages. The formatters will be called in the same order they are given to the aggregate. ```php (new AggregateMessageFormatter( new LocaleMessageFormatter('fr'), new MessageMapFormatter([ // … ], TranslationMessageFormatter::default(), ))->format($message) ``` BREAKING CHANGE: The method `NodeMessage::format` has been removed, message formatters should be used instead. If needed, the old behaviour can be retrieved with the formatter `PlaceHolderMessageFormatter`, although it is strongly advised to use the new placeholders feature. BREAKING CHANGE: The signature of the method `MessageFormatter::format` has changed. --- README.md | 244 ++++++++++++------ infection.json.dist | 1 + src/Mapper/Object/Arguments.php | 4 +- src/Mapper/Object/DateTimeObjectBuilder.php | 2 +- .../Exception/CannotFindObjectBuilder.php | 61 +++-- .../Exception/CannotParseToDateTime.php | 36 ++- .../Exception/InvalidSourceForInterface.php | 29 ++- .../Exception/InvalidSourceForObject.php | 30 ++- .../Exception/SeveralObjectBuildersFound.php | 29 ++- .../Exception/CannotCastToScalarValue.php | 37 ++- .../Tree/Exception/InvalidEnumValue.php | 43 ++- .../InvalidInterfaceResolverReturnType.php | 1 - .../Tree/Exception/InvalidNodeValue.php | 34 ++- .../Tree/Exception/InvalidTraversableKey.php | 31 ++- .../Exception/ShapedArrayElementMissing.php | 31 ++- .../Tree/Exception/SourceMustBeIterable.php | 38 ++- src/Mapper/Tree/Message/DefaultMessage.php | 78 ++++++ .../Formatter/AggregateMessageFormatter.php | 28 ++ .../Formatter/LocaleMessageFormatter.php | 23 ++ .../Message/Formatter/MessageFormatter.php | 2 +- .../Message/Formatter/MessageMapFormatter.php | 42 ++- .../Formatter/PlaceHolderMessageFormatter.php | 54 ++++ .../Formatter/TranslationMessageFormatter.php | 85 ++++++ src/Mapper/Tree/Message/NodeMessage.php | 117 +++++---- .../Tree/Message/TranslatableMessage.php | 16 ++ src/Mapper/Tree/Node.php | 4 +- .../Exception/CannotResolveTypeFromUnion.php | 39 ++- .../Exception/UnionTypeDoesNotAllowNull.php | 32 ++- src/Type/Types/Exception/CannotCastValue.php | 27 +- src/Type/Types/Exception/CastError.php | 4 +- .../Types/Exception/InvalidClassString.php | 45 +++- .../Exception/InvalidEmptyStringValue.php | 18 +- .../Types/Exception/InvalidFloatValue.php | 26 +- .../Types/Exception/InvalidFloatValueType.php | 28 +- .../Exception/InvalidIntegerRangeValue.php | 27 +- .../Types/Exception/InvalidIntegerValue.php | 26 +- .../Exception/InvalidIntegerValueType.php | 28 +- .../Exception/InvalidNegativeIntegerValue.php | 25 +- .../Exception/InvalidPositiveIntegerValue.php | 25 +- .../Types/Exception/InvalidStringValue.php | 27 +- .../Exception/InvalidStringValueType.php | 29 ++- src/Utility/Polyfill.php | 59 ----- src/Utility/String/StringFormatter.php | 70 +++++ src/Utility/String/StringFormatterError.php | 16 ++ src/Utility/TypeHelper.php | 22 +- tests/Fake/Mapper/FakeNode.php | 7 +- tests/Fake/Mapper/FakeShell.php | 25 ++ .../Mapper/Tree/Message/FakeErrorMessage.php | 9 +- .../Mapper/Tree/Message/FakeNodeMessage.php | 16 ++ .../Tree/Message/FakeTranslatableMessage.php | 45 ++++ .../Formatter/FakeMessageFormatter.php | 25 ++ .../ConstructorRegistrationMappingTest.php | 4 +- .../Mapping/InterfaceInferringMappingTest.php | 2 +- .../Mapping/Message/MessageFormatterTest.php | 38 +++ .../Mapping/Object/ArrayValuesMappingTest.php | 2 +- .../Mapping/Object/DateTimeMappingTest.php | 4 +- .../Mapping/Object/ListValuesMappingTest.php | 2 +- .../Object/ObjectValuesMappingTest.php | 2 +- .../Object/ScalarValuesMappingTest.php | 2 +- .../Mapping/VisitorMappingTest.php | 2 +- tests/Unit/Mapper/Object/ArgumentsTest.php | 5 +- .../Tree/Builder/ArrayNodeBuilderTest.php | 14 +- .../Tree/Builder/CasterNodeBuilderTest.php | 4 +- .../Tree/Builder/EnumNodeBuilderTest.php | 21 +- .../Tree/Builder/ListNodeBuilderTest.php | 10 +- .../Tree/Builder/ScalarNodeBuilderTest.php | 5 +- .../Builder/ShapedArrayNodeBuilderTest.php | 20 +- .../AggregateMessageFormatterTest.php | 25 ++ .../Formatter/LocaleMessageFormatterTest.php | 20 ++ .../Formatter/MessageMapFormatterTest.php | 61 +++-- .../PlaceHolderMessageFormatterTest.php | 48 ++++ .../TranslationMessageFormatterTest.php | 132 ++++++++++ .../Mapper/Tree/Message/NodeMessageTest.php | 105 +++++--- tests/Unit/Mapper/Tree/NodeTest.php | 14 +- .../Resolver/Union/UnionNullNarrowerTest.php | 2 +- .../Union/UnionScalarNarrowerTest.php | 2 +- .../Utility/String/StringFormatterTest.php | 44 ++++ 77 files changed, 1847 insertions(+), 543 deletions(-) create mode 100644 src/Mapper/Tree/Message/DefaultMessage.php create mode 100644 src/Mapper/Tree/Message/Formatter/AggregateMessageFormatter.php create mode 100644 src/Mapper/Tree/Message/Formatter/LocaleMessageFormatter.php create mode 100644 src/Mapper/Tree/Message/Formatter/PlaceHolderMessageFormatter.php create mode 100644 src/Mapper/Tree/Message/Formatter/TranslationMessageFormatter.php create mode 100644 src/Mapper/Tree/Message/TranslatableMessage.php create mode 100644 src/Utility/String/StringFormatter.php create mode 100644 src/Utility/String/StringFormatterError.php create mode 100644 tests/Fake/Mapper/FakeShell.php create mode 100644 tests/Fake/Mapper/Tree/Message/FakeNodeMessage.php create mode 100644 tests/Fake/Mapper/Tree/Message/FakeTranslatableMessage.php create mode 100644 tests/Fake/Mapper/Tree/Message/Formatter/FakeMessageFormatter.php create mode 100644 tests/Integration/Mapping/Message/MessageFormatterTest.php create mode 100644 tests/Unit/Mapper/Tree/Message/Formatter/AggregateMessageFormatterTest.php create mode 100644 tests/Unit/Mapper/Tree/Message/Formatter/LocaleMessageFormatterTest.php create mode 100644 tests/Unit/Mapper/Tree/Message/Formatter/PlaceHolderMessageFormatterTest.php create mode 100644 tests/Unit/Mapper/Tree/Message/Formatter/TranslationMessageFormatterTest.php create mode 100644 tests/Unit/Utility/String/StringFormatterTest.php diff --git a/README.md b/README.md index 707c599..3a7d9f3 100644 --- a/README.md +++ b/README.md @@ -196,29 +196,14 @@ final class SomeClass try { (new \CuyZ\Valinor\MapperBuilder()) - ->mapper() - ->map( - SomeClass::class, - ['someValue' => 'bar_baz'] - ); + ->mapper() + ->map( + SomeClass::class, + ['someValue' => 'bar_baz'] + ); } catch (\CuyZ\Valinor\Mapper\MappingError $error) { - $node = $error->node(); - - // The name of a node can be accessed - $name = $node->name(); - - // The logical path of a node contains dot separated names of its parents - $path = $node->path(); - - // The type of the node can be cast to string to enhance suggestion messages - $type = (string)$node->type(); - - // If the node is a branch, its children can be recursively accessed - foreach ($node->children() as $child) { - // Do something… - } - // Get flatten list of all messages through the whole nodes tree + $node = $error->node(); $messages = new \CuyZ\Valinor\Mapper\Tree\Message\MessagesFlattener($node); // If only errors are wanted, they can be filtered @@ -232,36 +217,24 @@ try { } ``` -### Message customization / translation +### Message customization -When working with messages, it can sometimes be useful to customize the content -of a message — for instance to translate it. +The content of a message can be changed to fit custom use cases; it can contain +placeholders that will be replaced with useful information. -The helper class `\CuyZ\Valinor\Mapper\Tree\Message\MessageMapFormatter` can be -used to provide a list of new formats. It can be instantiated with an array -where each key represents either: +The placeholders below are always available; even more may be used depending +on the original message. -- The code of the message to be replaced -- The content of the message to be replaced -- The class name of the message to be replaced +| Placeholder | Description | +|----------------------|:-----------------------------------------------------| +| `{message_code}` | the code of the message | +| `{node_name}` | name of the node to which the message is bound | +| `{node_path}` | path of the node to which the message is bound | +| `{node_type}` | type of the node to which the message is bound | +| `{original_value}` | the source value that was given to the node | +| `{original_message}` | the original message before being customized | -If none of those is found, the content of the message will stay unchanged unless -a default one is given to the class. - -If one of these keys is found, the array entry will be used to replace the -content of the message. This entry can be either a plain text or a callable that -takes the message as a parameter and returns a string; it is for instance -advised to use a callable in cases where a translation service is used — to -avoid useless greedy operations. - -In any case, the content can contain placeholders that will automatically be -replaced by, in order: - -1. The original code of the message -2. The original content of the message -3. A string representation of the node type -4. The name of the node -5. The path of the node +Usage: ```php try { @@ -272,47 +245,152 @@ try { $node = $error->node(); $messages = new \CuyZ\Valinor\Mapper\Tree\Message\MessagesFlattener($node); - $formatter = (new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\MessageMapFormatter([ - // Will match if the given message has this exact code - 'some_code' => 'new content / previous code was: %1$s', - - // Will match if the given message has this exact content - 'Some message content' => 'new content / previous message: %2$s', - - // Will match if the given message is an instance of `SomeError` - SomeError::class => ' - - Original code of the message: %1$s - - Original content of the message: %2$s - - Node type: %3$s - - Node name: %4$s - - Node path: %5$s - ', - - // A callback can be used to get access to the message instance - OtherError::class => function (NodeMessage $message): string { - if ((string)$message->type() === 'string|int') { - // … - } - - return 'Some message content'; - }, - - // For greedy operation, it is advised to use a lazy-callback - 'foo' => fn () => $this->translator->translate('foo.bar'), - ])) - ->defaultsTo('some default message') - // …or… - ->defaultsTo(fn () => $this->translator->translate('default_message')); - foreach ($messages as $message) { - echo $formatter->format($message); + if ($message->code() === 'some_code') { + $message = $message->withBody('new message / {original_message}'); + } + + echo $message; } } ``` +The messages are formatted using the [ICU library], enabling the placeholders to +use advanced syntax to perform proper translations, for instance currency +support. + +```php +try { + (new \CuyZ\Valinor\MapperBuilder())->mapper()->map('int<0, 100>', 1337); +} catch (\CuyZ\Valinor\Mapper\MappingError $error) { + $message = $error->node()->messages()[0]; + + if (is_numeric($message->value())) { + $message = $message->withBody( + 'Invalid amount {original_value, number, currency}' + ); + } + + // Invalid amount: $1,337.00 + echo $message->withLocale('en_US'); + + // Invalid amount: £1,337.00 + echo $message->withLocale('en_GB'); + + // Invalid amount: 1 337,00 € + echo $message->withLocale('fr_FR'); +} +``` + +See [ICU documentation] for more information on available syntax. + +If the `intl` extension is not installed, a shim will be available to replace +the placeholders, but it won't handle advanced syntax as described above. + +### Deeper message customization / translation + +For deeper message changes, formatters can be used — for instance to translate +content. + +#### Translation + +The formatter `TranslationMessageFormatter` can be used to translate the content +of messages. + +The library provides a list of all messages that can be returned; this list can +be filled or modified with custom translations. + +```php +\CuyZ\Valinor\Mapper\Tree\Message\Formatter\TranslationMessageFormatter::default() + // Create/override a single entry… + ->withTranslation('fr', 'some custom message', 'un message personnalisé') + // …or several entries. + ->withTranslations([ + 'some custom message' => [ + 'en' => 'Some custom message', + 'fr' => 'Un message personnalisé', + 'es' => 'Un mensaje personalizado', + ], + 'some other message' => [ + // … + ], + ]) + ->format($message); +``` + +#### Replacement map + +The formatter `MessageMapFormatter` can be used to provide a list of messages +replacements. It can be instantiated with an array where each key represents +either: + +- The code of the message to be replaced +- The body of the message to be replaced +- The class name of the message to be replaced + +If none of those is found, the content of the message will stay unchanged unless +a default one is given to the class. + +If one of these keys is found, the array entry will be used to replace the +content of the message. This entry can be either a plain text or a callable that +takes the message as a parameter and returns a string; it is for instance +advised to use a callable in cases where a custom translation service is used — +to avoid useless greedy operations. + +In any case, the content can contain placeholders as described +[above](#message-customization). + +```php +(new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\MessageMapFormatter([ + // Will match if the given message has this exact code + 'some_code' => 'New content / code: {message_code}', + + // Will match if the given message has this exact content + 'Some message content' => 'New content / previous: {original_message}', + + // Will match if the given message is an instance of `SomeError` + SomeError::class => 'New content / value: {original_value}', + + // A callback can be used to get access to the message instance + OtherError::class => function (NodeMessage $message): string { + if ($message->path() === 'foo.bar') { + return 'Some custom message'; + } + + return $message->body(); + }, + + // For greedy operation, it is advised to use a lazy-callback + 'foo' => fn () => $this->customTranslator->translate('foo.bar'), +])) + ->defaultsTo('some default message') + // …or… + ->defaultsTo(fn () => $this->customTranslator->translate('default_message')) + ->format($message); +``` + +#### Several formatters + +It is possible to join several formatters into one formatter by using the +`AggregateMessageFormatter`. This instance can then easily be injected in a +service that will handle messages. + +The formatters will be called in the same order they are given to the aggregate. + +```php +(new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\AggregateMessageFormatter( + new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\LocaleMessageFormatter('fr'), + new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\MessageMapFormatter([ + // … + ], + \CuyZ\Valinor\Mapper\Tree\Message\Formatter\TranslationMessageFormatter::default(), +))->format($message) +``` + ### Source -Any source can be given to the mapper, be it an array, some json, yaml or even a file: +Any source can be given to the mapper, be it an array, some json, yaml or even a +file: ```php function map($source) { @@ -895,3 +973,7 @@ includes: [Webmozart Assert]: https://github.com/webmozarts/assert [link-packagist]: https://packagist.org/packages/cuyz/valinor + +[ICU library]: https://unicode-org.github.io/icu/ + +[ICU documentation]: https://unicode-org.github.io/icu/userguide/format_parse/messages/ diff --git a/infection.json.dist b/infection.json.dist index 04acff8..75ba3b5 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -5,6 +5,7 @@ "src" ] }, + "tmpDir": "var/cache/infection", "logs": { "text": "var/infection/infection.log", "summary": "var/infection/summary.log", diff --git a/src/Mapper/Object/Arguments.php b/src/Mapper/Object/Arguments.php index 5a51881..254ba46 100644 --- a/src/Mapper/Object/Arguments.php +++ b/src/Mapper/Object/Arguments.php @@ -41,14 +41,14 @@ final class Arguments implements IteratorAggregate, Countable $name = $argument->name(); $type = $argument->type(); - $signature = TypeHelper::containsObject($type) ? '?' : $type; + $signature = TypeHelper::dump($type, false); return $argument->isRequired() ? "$name: $signature" : "$name?: $signature"; }, $this->arguments ); - return 'array{' . implode(', ', $parameters) . '}'; + return '`array{' . implode(', ', $parameters) . '}`'; } public function count(): int diff --git a/src/Mapper/Object/DateTimeObjectBuilder.php b/src/Mapper/Object/DateTimeObjectBuilder.php index c98da63..ecd0d4a 100644 --- a/src/Mapper/Object/DateTimeObjectBuilder.php +++ b/src/Mapper/Object/DateTimeObjectBuilder.php @@ -84,7 +84,7 @@ final class DateTimeObjectBuilder implements ObjectBuilder if (! $date) { // @PHP8.0 use throw exception expression - throw new CannotParseToDateTime((string)$datetime); + throw new CannotParseToDateTime($datetime); } return $date; diff --git a/src/Mapper/Object/Exception/CannotFindObjectBuilder.php b/src/Mapper/Object/Exception/CannotFindObjectBuilder.php index 5803c51..c26d55b 100644 --- a/src/Mapper/Object/Exception/CannotFindObjectBuilder.php +++ b/src/Mapper/Object/Exception/CannotFindObjectBuilder.php @@ -6,48 +6,65 @@ namespace CuyZ\Valinor\Mapper\Object\Exception; use CuyZ\Valinor\Mapper\Object\Factory\SuitableObjectBuilderNotFound; use CuyZ\Valinor\Mapper\Object\ObjectBuilder; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; use function array_keys; use function count; -use function implode; use function ksort; /** @api */ -final class CannotFindObjectBuilder extends RuntimeException implements Message, SuitableObjectBuilderNotFound +final class CannotFindObjectBuilder extends RuntimeException implements TranslatableMessage, SuitableObjectBuilderNotFound { + private string $body = 'Value {value} does not match any of {allowed_types}.'; + + /** @var array */ + private array $parameters; + /** * @param mixed $source * @param non-empty-list $builders */ public function __construct($source, array $builders) { - $value = ValueDumper::dump($source); + $this->parameters = [ + 'value' => ValueDumper::dump($source), + 'allowed_types' => (function () use ($builders) { + $signatures = []; + $sortedSignatures = []; - $signatures = []; - $sortedSignatures = []; + foreach ($builders as $builder) { + $arguments = $builder->describeArguments(); + $count = count($arguments); + $signature = $arguments->signature(); - foreach ($builders as $builder) { - $arguments = $builder->describeArguments(); - $count = count($arguments); - $signature = $arguments->signature(); + $signatures[$count][$signature] = null; + } - $signatures[$count][$signature] = null; - } + ksort($signatures); - ksort($signatures); + foreach ($signatures as $list) { + foreach (array_keys($list) as $signature) { + $sortedSignatures[] = $signature; + } + } - foreach ($signatures as $list) { - foreach (array_keys($list) as $signature) { - $sortedSignatures[] = $signature; - } - } + return implode(', ', $sortedSignatures); + })(), + ]; - parent::__construct( - "Value $value does not match any of `" . implode('`, `', $sortedSignatures) . '`.', - 1642183169 - ); + parent::__construct(StringFormatter::for($this), 1642183169); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Object/Exception/CannotParseToDateTime.php b/src/Mapper/Object/Exception/CannotParseToDateTime.php index 1e9c082..b6b2c26 100644 --- a/src/Mapper/Object/Exception/CannotParseToDateTime.php +++ b/src/Mapper/Object/Exception/CannotParseToDateTime.php @@ -4,20 +4,38 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Object\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ -final class CannotParseToDateTime extends RuntimeException implements Message +final class CannotParseToDateTime extends RuntimeException implements TranslatableMessage { - public function __construct(string $datetime) - { - $datetime = ValueDumper::dump($datetime); + private string $body = 'Value {value} does not match a valid date format.'; - parent::__construct( - "Impossible to parse date with value $datetime.", - 1630686564 - ); + /** @var array */ + private array $parameters; + + /** + * @param string|int $datetime + */ + public function __construct($datetime) + { + $this->parameters = [ + 'value' => ValueDumper::dump($datetime), + ]; + + parent::__construct(StringFormatter::for($this), 1630686564); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Object/Exception/InvalidSourceForInterface.php b/src/Mapper/Object/Exception/InvalidSourceForInterface.php index 2886c83..a1f993e 100644 --- a/src/Mapper/Object/Exception/InvalidSourceForInterface.php +++ b/src/Mapper/Object/Exception/InvalidSourceForInterface.php @@ -4,23 +4,38 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Object\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ -final class InvalidSourceForInterface extends RuntimeException implements Message +final class InvalidSourceForInterface extends RuntimeException implements TranslatableMessage { + private string $body = 'Invalid value {value}: it must be an array.'; + + /** @var array */ + private array $parameters; + /** * @param mixed $source */ public function __construct($source) { - $type = ValueDumper::dump($source); + $this->parameters = [ + 'value' => ValueDumper::dump($source), + ]; - parent::__construct( - "Invalid value $type, it must be an iterable.", - 1645283485 - ); + parent::__construct(StringFormatter::for($this), 1645283485); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Object/Exception/InvalidSourceForObject.php b/src/Mapper/Object/Exception/InvalidSourceForObject.php index 558efe7..fab42ee 100644 --- a/src/Mapper/Object/Exception/InvalidSourceForObject.php +++ b/src/Mapper/Object/Exception/InvalidSourceForObject.php @@ -5,23 +5,39 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Object\Exception; use CuyZ\Valinor\Mapper\Object\Arguments; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ -final class InvalidSourceForObject extends RuntimeException implements Message +final class InvalidSourceForObject extends RuntimeException implements TranslatableMessage { + private string $body = 'Value {value} does not match type {expected_type}.'; + + /** @var array */ + private array $parameters; + /** * @param mixed $source */ public function __construct($source, Arguments $arguments) { - $value = ValueDumper::dump($source); + $this->parameters = [ + 'value' => ValueDumper::dump($source), + 'expected_type' => $arguments->signature(), + ]; - parent::__construct( - "Value $value does not match `{$arguments->signature()}`.", - 1632903281 - ); + parent::__construct(StringFormatter::for($this), 1632903281); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Object/Exception/SeveralObjectBuildersFound.php b/src/Mapper/Object/Exception/SeveralObjectBuildersFound.php index ab28913..fcded32 100644 --- a/src/Mapper/Object/Exception/SeveralObjectBuildersFound.php +++ b/src/Mapper/Object/Exception/SeveralObjectBuildersFound.php @@ -5,23 +5,38 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Object\Exception; use CuyZ\Valinor\Mapper\Object\Factory\SuitableObjectBuilderNotFound; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ -final class SeveralObjectBuildersFound extends RuntimeException implements Message, SuitableObjectBuilderNotFound +final class SeveralObjectBuildersFound extends RuntimeException implements TranslatableMessage, SuitableObjectBuilderNotFound { + private string $body = 'Invalid value {value}.'; + + /** @var array */ + private array $parameters; + /** * @param mixed $source */ public function __construct($source) { - $value = ValueDumper::dump($source); + $this->parameters = [ + 'value' => ValueDumper::dump($source), + ]; - parent::__construct( - "Value $value is not accepted.", - 1642787246 - ); + parent::__construct(StringFormatter::for($this), 1642787246); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Tree/Exception/CannotCastToScalarValue.php b/src/Mapper/Tree/Exception/CannotCastToScalarValue.php index 2ae5e7f..9c78f1c 100644 --- a/src/Mapper/Tree/Exception/CannotCastToScalarValue.php +++ b/src/Mapper/Tree/Exception/CannotCastToScalarValue.php @@ -4,26 +4,45 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Tree\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; use CuyZ\Valinor\Type\ScalarType; +use CuyZ\Valinor\Utility\String\StringFormatter; +use CuyZ\Valinor\Utility\TypeHelper; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ -final class CannotCastToScalarValue extends RuntimeException implements Message +final class CannotCastToScalarValue extends RuntimeException implements TranslatableMessage { + private string $body; + + /** @var array */ + private array $parameters; + /** * @param mixed $value */ public function __construct($value, ScalarType $type) { - if ($value === null || $value === []) { - $message = "Cannot be empty and must be filled with a value of type `$type`."; - } else { - $value = ValueDumper::dump($value); - $message = "Cannot cast $value to `$type`."; - } + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'expected_type' => TypeHelper::dump($type), + ]; - parent::__construct($message, 1618736242); + $this->body = $value === null || $value === [] + ? 'Cannot be empty and must be filled with a value matching type {expected_type}.' + : 'Cannot cast {value} to {expected_type}.'; + + parent::__construct(StringFormatter::for($this), 1618736242); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Tree/Exception/InvalidEnumValue.php b/src/Mapper/Tree/Exception/InvalidEnumValue.php index 865b5c9..3e6dbaa 100644 --- a/src/Mapper/Tree/Exception/InvalidEnumValue.php +++ b/src/Mapper/Tree/Exception/InvalidEnumValue.php @@ -5,7 +5,8 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Tree\Exception; use BackedEnum; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; use UnitEnum; @@ -14,27 +15,41 @@ use function array_map; use function implode; /** @api */ -final class InvalidEnumValue extends RuntimeException implements Message +final class InvalidEnumValue extends RuntimeException implements TranslatableMessage { + private string $body = 'Value {value} does not match any of {allowed_values}.'; + + /** @var array */ + private array $parameters; + /** * @param class-string $enumName * @param mixed $value */ public function __construct(string $enumName, $value) { - $values = array_map( - static function (UnitEnum $case) { - return ValueDumper::dump($case instanceof BackedEnum ? $case->value : $case->name); - }, - $enumName::cases() - ); + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'allowed_values' => (function () use ($enumName) { + $values = array_map( + fn (UnitEnum $case) => ValueDumper::dump($case instanceof BackedEnum ? $case->value : $case->name), + $enumName::cases() + ); - $values = implode(', ', $values); - $value = ValueDumper::dump($value); + return implode(', ', $values); + })(), + ]; - parent::__construct( - "Invalid value $value, it must be one of $values.", - 1633093113 - ); + parent::__construct(StringFormatter::for($this), 1633093113); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Tree/Exception/InvalidInterfaceResolverReturnType.php b/src/Mapper/Tree/Exception/InvalidInterfaceResolverReturnType.php index 79a96b7..0dc5eb9 100644 --- a/src/Mapper/Tree/Exception/InvalidInterfaceResolverReturnType.php +++ b/src/Mapper/Tree/Exception/InvalidInterfaceResolverReturnType.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Tree\Exception; -use CuyZ\Valinor\Utility\Polyfill; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; diff --git a/src/Mapper/Tree/Exception/InvalidNodeValue.php b/src/Mapper/Tree/Exception/InvalidNodeValue.php index e8b054f..177ab85 100644 --- a/src/Mapper/Tree/Exception/InvalidNodeValue.php +++ b/src/Mapper/Tree/Exception/InvalidNodeValue.php @@ -4,25 +4,45 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Tree\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; use CuyZ\Valinor\Type\Type; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\TypeHelper; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ -final class InvalidNodeValue extends RuntimeException implements Message +final class InvalidNodeValue extends RuntimeException implements TranslatableMessage { + private string $body; + + /** @var array */ + private array $parameters; + /** * @param mixed $value */ public function __construct($value, Type $type) { - $value = ValueDumper::dump($value); - $message = TypeHelper::containsObject($type) - ? "Value $value is not accepted." - : "Value $value does not match expected `$type`."; + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'expected_type' => TypeHelper::dump($type), + ]; - parent::__construct($message, 1630678334); + $this->body = TypeHelper::containsObject($type) + ? 'Invalid value {value}.' + : 'Value {value} does not match type {expected_type}.'; + + parent::__construct(StringFormatter::for($this), 1630678334); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Tree/Exception/InvalidTraversableKey.php b/src/Mapper/Tree/Exception/InvalidTraversableKey.php index f9c7960..9429265 100644 --- a/src/Mapper/Tree/Exception/InvalidTraversableKey.php +++ b/src/Mapper/Tree/Exception/InvalidTraversableKey.php @@ -4,24 +4,41 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Tree\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; use CuyZ\Valinor\Type\Types\ArrayKeyType; +use CuyZ\Valinor\Utility\String\StringFormatter; +use CuyZ\Valinor\Utility\TypeHelper; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ -final class InvalidTraversableKey extends RuntimeException implements Message +final class InvalidTraversableKey extends RuntimeException implements TranslatableMessage { + private string $body = 'Key {key} does not match type {expected_type}.'; + + /** @var array */ + private array $parameters; + /** * @param string|int $key */ public function __construct($key, ArrayKeyType $type) { - $key = ValueDumper::dump($key); + $this->parameters = [ + 'key' => ValueDumper::dump($key), + 'expected_type' => TypeHelper::dump($type), + ]; - parent::__construct( - "Invalid key $key, it must be of type `$type`.", - 1630946163 - ); + parent::__construct(StringFormatter::for($this), 1630946163); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Tree/Exception/ShapedArrayElementMissing.php b/src/Mapper/Tree/Exception/ShapedArrayElementMissing.php index a9fa432..d1047e8 100644 --- a/src/Mapper/Tree/Exception/ShapedArrayElementMissing.php +++ b/src/Mapper/Tree/Exception/ShapedArrayElementMissing.php @@ -4,18 +4,37 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Tree\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; use CuyZ\Valinor\Type\Types\ShapedArrayElement; +use CuyZ\Valinor\Utility\String\StringFormatter; +use CuyZ\Valinor\Utility\TypeHelper; use RuntimeException; /** @api */ -final class ShapedArrayElementMissing extends RuntimeException implements Message +final class ShapedArrayElementMissing extends RuntimeException implements TranslatableMessage { + private string $body = 'Missing element {element} matching type {expected_type}.'; + + /** @var array */ + private array $parameters; + public function __construct(ShapedArrayElement $element) { - parent::__construct( - "Missing element `{$element->key()}` of type `{$element->type()}`.", - 1631613641 - ); + $this->parameters = [ + 'element' => "`{$element->key()}`", + 'expected_type' => TypeHelper::dump($element->type()), + ]; + + parent::__construct(StringFormatter::for($this), 1631613641); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Tree/Exception/SourceMustBeIterable.php b/src/Mapper/Tree/Exception/SourceMustBeIterable.php index d9cc38e..d224c93 100644 --- a/src/Mapper/Tree/Exception/SourceMustBeIterable.php +++ b/src/Mapper/Tree/Exception/SourceMustBeIterable.php @@ -4,31 +4,51 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Tree\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; use CuyZ\Valinor\Type\Type; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\TypeHelper; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ -final class SourceMustBeIterable extends RuntimeException implements Message +final class SourceMustBeIterable extends RuntimeException implements TranslatableMessage { + private string $body; + + /** @var array */ + private array $parameters; + /** * @param mixed $value */ public function __construct($value, Type $type) { + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'expected_type' => TypeHelper::dump($type), + ]; + if ($value === null) { - $message = TypeHelper::containsObject($type) + $this->body = TypeHelper::containsObject($type) ? 'Cannot be empty.' - : "Cannot be empty and must be filled with a value matching `$type`."; + : 'Cannot be empty and must be filled with a value matching type {expected_type}.'; } else { - $value = ValueDumper::dump($value); - $message = TypeHelper::containsObject($type) - ? "Value $value is not accepted." - : "Value $value does not match expected `$type`."; + $this->body = TypeHelper::containsObject($type) + ? 'Invalid value {value}.' + : 'Value {value} does not match type {expected_type}.'; } - parent::__construct($message, 1618739163); + parent::__construct(StringFormatter::for($this), 1618739163); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Tree/Message/DefaultMessage.php b/src/Mapper/Tree/Message/DefaultMessage.php new file mode 100644 index 0000000..4605eda --- /dev/null +++ b/src/Mapper/Tree/Message/DefaultMessage.php @@ -0,0 +1,78 @@ + [ + 'en' => 'Value {value} does not match expected {expected_value}.', + ], + 'Value {value} does not match any of {allowed_values}.' => [ + 'en' => 'Value {value} does not match any of {allowed_values}.', + ], + 'Value {value} does not match any of {allowed_types}.' => [ + 'en' => 'Value {value} does not match any of {allowed_types}.', + ], + 'Value {value} does not match type {expected_type}.' => [ + 'en' => 'Value {value} does not match type {expected_type}.', + ], + 'Value {value} does not match float value {expected_value}.' => [ + 'en' => 'Value {value} does not match float value {expected_value}.', + ], + 'Value {value} does not match integer value {expected_value}.' => [ + 'en' => 'Value {value} does not match integer value {expected_value}.', + ], + 'Value {value} does not match string value {expected_value}.' => [ + 'en' => 'Value {value} does not match string value {expected_value}.', + ], + 'Invalid value {value}.' => [ + 'en' => 'Invalid value {value}.', + ], + 'Invalid value {value}: it must be an array.' => [ + 'en' => 'Invalid value {value}: it must be an array.', + ], + 'Invalid value {value}: it must be an integer between {min} and {max}.' => [ + 'en' => 'Invalid value {value}: it must be an integer between {min} and {max}.', + ], + 'Invalid value {value}: it must be a negative integer.' => [ + 'en' => 'Invalid value {value}: it must be a negative integer.', + ], + 'Invalid value {value}: it must be a positive integer.' => [ + 'en' => 'Invalid value {value}: it must be a positive integer.', + ], + 'Cannot cast {value} to {expected_type}.' => [ + 'en' => 'Cannot cast {value} to {expected_type}.', + ], + 'Cannot be empty.' => [ + 'en' => 'Cannot be empty.', + ], + 'Cannot be empty and must be filled with a valid string value.' => [ + 'en' => 'Cannot be empty and must be filled with a valid string value.', + ], + 'Cannot be empty and must be filled with a value matching type {expected_type}.' => [ + 'en' => 'Cannot be empty and must be filled with a value matching type {expected_type}.', + ], + 'Key {key} does not match type {expected_type}.' => [ + 'en' => 'Key {key} does not match type {expected_type}.', + ], + 'Missing element {element} matching type {expected_type}.' => [ + 'en' => 'Missing element {element} matching type {expected_type}.', + ], + 'Value {value} does not match a valid date format.' => [ + 'en' => 'Value {value} does not match a valid date format.', + ], + 'Invalid class string {value}, it must be one of {expected_class_strings}.' => [ + 'en' => 'Invalid class string {value}, it must be one of {expected_class_strings}.', + ], + 'Invalid class string {value}, it must be a subtype of {expected_class_strings}.' => [ + 'en' => 'Invalid class string {value}, it must be a subtype of {expected_class_strings}.', + ], + 'Invalid class string {value}.' => [ + 'en' => 'Invalid class string {value}.', + ], + ]; +} diff --git a/src/Mapper/Tree/Message/Formatter/AggregateMessageFormatter.php b/src/Mapper/Tree/Message/Formatter/AggregateMessageFormatter.php new file mode 100644 index 0000000..d4d3038 --- /dev/null +++ b/src/Mapper/Tree/Message/Formatter/AggregateMessageFormatter.php @@ -0,0 +1,28 @@ +formatters = $formatters; + } + + public function format(NodeMessage $message): NodeMessage + { + foreach ($this->formatters as $formatter) { + $message = $formatter->format($message); + } + + return $message; + } +} diff --git a/src/Mapper/Tree/Message/Formatter/LocaleMessageFormatter.php b/src/Mapper/Tree/Message/Formatter/LocaleMessageFormatter.php new file mode 100644 index 0000000..d47ab3a --- /dev/null +++ b/src/Mapper/Tree/Message/Formatter/LocaleMessageFormatter.php @@ -0,0 +1,23 @@ +locale = $locale; + } + + public function format(NodeMessage $message): NodeMessage + { + return $message->withLocale($this->locale); + } +} diff --git a/src/Mapper/Tree/Message/Formatter/MessageFormatter.php b/src/Mapper/Tree/Message/Formatter/MessageFormatter.php index d0de234..8df6910 100644 --- a/src/Mapper/Tree/Message/Formatter/MessageFormatter.php +++ b/src/Mapper/Tree/Message/Formatter/MessageFormatter.php @@ -9,5 +9,5 @@ use CuyZ\Valinor\Mapper\Tree\Message\NodeMessage; /** @api */ interface MessageFormatter { - public function format(NodeMessage $message): string; + public function format(NodeMessage $message): NodeMessage; } diff --git a/src/Mapper/Tree/Message/Formatter/MessageMapFormatter.php b/src/Mapper/Tree/Message/Formatter/MessageMapFormatter.php index 399800f..4d02f24 100644 --- a/src/Mapper/Tree/Message/Formatter/MessageMapFormatter.php +++ b/src/Mapper/Tree/Message/Formatter/MessageMapFormatter.php @@ -14,7 +14,7 @@ use function is_callable; * * The constructor parameter is an array where each key represents either: * - The code of the message to be replaced - * - The content of the message to be replaced + * - The body of the message to be replaced * - The class name of the message to be replaced * * If none of those is found, the content of the message will stay unchanged @@ -23,38 +23,29 @@ use function is_callable; * If one of these keys is found, the array entry will be used to replace the * content of the message. This entry can be either a plain text or a callable * that takes the message as a parameter and returns a string; it is for - * instance advised to use a callable in cases where a translation service is - * used — to avoid useless greedy operations. - * - * In any case, the content can contain placeholders that can be used the same - * way as @see \CuyZ\Valinor\Mapper\Tree\Message\NodeMessage::format() + * instance advised to use a callable in cases where a custom translation + * service is used — to avoid useless greedy operations. * * See usage examples below: * * ```php * $formatter = (new MessageMapFormatter([ * // Will match if the given message has this exact code - * 'some_code' => 'new content / previous code was: %1$s', + * 'some_code' => 'New content / code: {message_code}', * * // Will match if the given message has this exact content - * 'Some message content' => 'new content / previous message: %2$s', + * 'Some message content' => 'New content / previous: {original_message}', * * // Will match if the given message is an instance of `SomeError` - * SomeError::class => ' - * - Original code of the message: %1$s - * - Original content of the message: %2$s - * - Node type: %3$s - * - Node name: %4$s - * - Node path: %5$s - * ', + * SomeError::class => 'New content / value: {original_value}', * * // A callback can be used to get access to the message instance * OtherError::class => function (NodeMessage $message): string { - * if ((string)$message->type() === 'string|int') { - * // … + * if ($message->path() === 'foo.bar') { + * return 'Some custom message'; * } * - * return 'Some message content'; + * return $message->body(); * }, * * // For greedy operation, it is advised to use a lazy-callback @@ -85,12 +76,15 @@ final class MessageMapFormatter implements MessageFormatter $this->map = $map; } - public function format(NodeMessage $message): string + public function format(NodeMessage $message): NodeMessage { $target = $this->target($message); - $text = is_callable($target) ? $target($message) : $target; - return $message->format($text); + if ($target) { + return $message->withBody(is_callable($target) ? $target($message) : $target); + } + + return $message; } /** @@ -105,14 +99,14 @@ final class MessageMapFormatter implements MessageFormatter } /** - * @return string|callable(NodeMessage): string + * @return false|string|callable(NodeMessage): string */ private function target(NodeMessage $message) { return $this->map[$message->code()] - ?? $this->map[(string)$message] + ?? $this->map[$message->body()] ?? $this->map[get_class($message->originalMessage())] ?? $this->default - ?? (string)$message; + ?? false; } } diff --git a/src/Mapper/Tree/Message/Formatter/PlaceHolderMessageFormatter.php b/src/Mapper/Tree/Message/Formatter/PlaceHolderMessageFormatter.php new file mode 100644 index 0000000..93692f0 --- /dev/null +++ b/src/Mapper/Tree/Message/Formatter/PlaceHolderMessageFormatter.php @@ -0,0 +1,54 @@ +values = $values; + } + + public function format(NodeMessage $message): NodeMessage + { + $originalMessage = $message->originalMessage(); + + $body = sprintf($message->body(), ...$this->values ?: [ + $message->code(), + $originalMessage instanceof Throwable ? $originalMessage->getMessage() : $originalMessage->__toString(), + TypeHelper::dump($message->type()), + $message->name(), + $message->path(), + ]); + + return $message->withBody($body); + } +} diff --git a/src/Mapper/Tree/Message/Formatter/TranslationMessageFormatter.php b/src/Mapper/Tree/Message/Formatter/TranslationMessageFormatter.php new file mode 100644 index 0000000..5fff633 --- /dev/null +++ b/src/Mapper/Tree/Message/Formatter/TranslationMessageFormatter.php @@ -0,0 +1,85 @@ +> */ + private array $translations = []; + + /** + * Returns an instance of the class with the default translations provided + * by the library. + */ + public static function default(): self + { + $instance = new self(); + $instance->translations = DefaultMessage::TRANSLATIONS; + + return $instance; + } + + /** + * Creates or overrides a single translation. + * + * ```php + * (TranslationMessageFormatter::default())->withTranslation( + * 'fr', + * 'Invalid value {value}.', + * 'Valeur invalide {value}.', + * ); + * ``` + */ + public function withTranslation(string $locale, string $original, string $translation): self + { + $clone = clone $this; + $clone->translations[$original][$locale] = $translation; + + return $clone; + } + + /** + * Creates or overrides a list of translations. + * + * The given array consists of messages to be translated and for each one a + * list of locales with their associated translations. + * + * ```php + * (TranslationMessageFormatter::default())->withTranslations([ + * 'Invalid value {value}.' => [ + * 'fr' => 'Valeur invalide {value}.', + * 'es' => 'Valor inválido {value}.', + * ], + * 'Some custom message' => [ + * // … + * ], + * ]); + * ``` + * + * @param array> $translations + */ + public function withTranslations(array $translations): self + { + $clone = clone $this; + $clone->translations = array_replace_recursive($this->translations, $translations); + + return $clone; + } + + public function format(NodeMessage $message): NodeMessage + { + $body = $this->translations[$message->body()][$message->locale()] ?? null; + + if ($body) { + return $message->withBody($body); + } + + return $message; + } +} diff --git a/src/Mapper/Tree/Message/NodeMessage.php b/src/Mapper/Tree/Message/NodeMessage.php index 01a30a4..908855e 100644 --- a/src/Mapper/Tree/Message/NodeMessage.php +++ b/src/Mapper/Tree/Message/NodeMessage.php @@ -5,78 +5,82 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Tree\Message; use CuyZ\Valinor\Definition\Attributes; -use CuyZ\Valinor\Mapper\Tree\Node; +use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Type\Type; +use CuyZ\Valinor\Utility\String\StringFormatter; +use CuyZ\Valinor\Utility\TypeHelper; +use CuyZ\Valinor\Utility\ValueDumper; use Throwable; -use function sprintf; - /** @api */ final class NodeMessage implements Message, HasCode { - private Node $node; + private Shell $shell; private Message $message; - public function __construct(Node $node, Message $message) + private string $body; + + private string $locale = StringFormatter::DEFAULT_LOCALE; + + public function __construct(Shell $shell, Message $message) { - $this->node = $node; + $this->shell = $shell; $this->message = $message; + + if ($this->message instanceof TranslatableMessage) { + $this->body = $this->message->body(); + } elseif ($this->message instanceof Throwable) { + $this->body = $this->message->getMessage(); + } else { + $this->body = (string)$this->message; + } } - /** - * Performs a placeholders replace operation on the given content. - * - * The values to be replaced will be the ones given as second argument; if - * none is given these values will be used instead, in order: - * - * 1. The original code of this message - * 2. The original content of this message - * 3. A string representation of the node type - * 4. The name of the node - * 5. The path of the node - * - * See usage examples below: - * - * ```php - * $content = $message->format('the previous code was: %1$s'); - * - * $content = $message->format( - * '%1$s / new message content (type: %2$s)', - * 'some parameter', - * $message->type(), - * ); - * ``` - */ - public function format(string $content, string ...$values): string + public function withLocale(string $locale): self { - return sprintf($content, ...$values ?: [ - $this->code(), - (string)$this, - (string)$this->type(), - $this->name(), - $this->path(), - ]); + $clone = clone $this; + $clone->locale = $locale; + + return $clone; + } + + public function locale(): string + { + return $this->locale; + } + + public function withBody(string $body): self + { + $clone = clone $this; + $clone->body = $body; + + return $clone; + } + + public function body(): string + { + return $this->body; } public function name(): string { - return $this->node->name(); + return $this->shell->name(); } public function path(): string { - return $this->node->path(); + return $this->shell->path(); } public function type(): Type { - return $this->node->type(); + return $this->shell->type(); } public function attributes(): Attributes { - return $this->node->attributes(); + return $this->shell->attributes(); } /** @@ -84,11 +88,7 @@ final class NodeMessage implements Message, HasCode */ public function value() { - if (! $this->node->isValid()) { - return null; - } - - return $this->node->value(); + return $this->shell->value(); } public function originalMessage(): Message @@ -116,10 +116,27 @@ final class NodeMessage implements Message, HasCode public function __toString(): string { - if ($this->message instanceof Throwable) { - return $this->message->getMessage(); + return StringFormatter::format($this->locale, $this->body, $this->parameters()); + } + + /** + * @return array + */ + private function parameters(): array + { + $parameters = [ + 'message_code' => $this->code(), + 'node_name' => $this->shell->name(), + 'node_path' => $this->shell->path(), + 'node_type' => TypeHelper::dump($this->shell->type()), + 'original_value' => ValueDumper::dump($this->shell->value()), + 'original_message' => $this->message instanceof Throwable ? $this->message->getMessage() : $this->message->__toString(), + ]; + + if ($this->message instanceof TranslatableMessage) { + $parameters += $this->message->parameters(); } - return (string)$this->message; + return $parameters; } } diff --git a/src/Mapper/Tree/Message/TranslatableMessage.php b/src/Mapper/Tree/Message/TranslatableMessage.php new file mode 100644 index 0000000..6f87291 --- /dev/null +++ b/src/Mapper/Tree/Message/TranslatableMessage.php @@ -0,0 +1,16 @@ + + */ + public function parameters(): array; +} diff --git a/src/Mapper/Tree/Node.php b/src/Mapper/Tree/Node.php index 605f161..53aada3 100644 --- a/src/Mapper/Tree/Node.php +++ b/src/Mapper/Tree/Node.php @@ -139,10 +139,10 @@ final class Node public function withMessage(Message $message): self { - $message = new NodeMessage($this, $message); + $message = new NodeMessage($this->shell, $message); $clone = clone $this; - $clone->messages = [...$this->messages, $message]; + $clone->messages[] = $message; $clone->valid = $clone->valid && ! $message->isError(); return $clone; diff --git a/src/Type/Resolver/Exception/CannotResolveTypeFromUnion.php b/src/Type/Resolver/Exception/CannotResolveTypeFromUnion.php index 795046c..808d03b 100644 --- a/src/Type/Resolver/Exception/CannotResolveTypeFromUnion.php +++ b/src/Type/Resolver/Exception/CannotResolveTypeFromUnion.php @@ -4,27 +4,52 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Resolver\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; use CuyZ\Valinor\Type\Types\UnionType; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\TypeHelper; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; +use function array_map; use function implode; /** @api */ -final class CannotResolveTypeFromUnion extends RuntimeException implements Message +final class CannotResolveTypeFromUnion extends RuntimeException implements TranslatableMessage { + private string $body; + + /** @var array */ + private array $parameters; + /** * @param mixed $value */ public function __construct(UnionType $unionType, $value) { - $value = ValueDumper::dump($value); - $message = TypeHelper::containsObject($unionType) - ? "Value $value is not accepted." - : "Value $value does not match any of `" . implode('`, `', $unionType->types()) . "`."; + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'allowed_types' => implode( + ', ', + // @PHP8.1 First-class callable syntax + array_map([TypeHelper::class, 'dump'], $unionType->types()) + ), + ]; - parent::__construct($message, 1607027306); + $this->body = TypeHelper::containsObject($unionType) + ? 'Invalid value {value}.' + : 'Value {value} does not match any of {allowed_types}.'; + + parent::__construct(StringFormatter::for($this), 1607027306); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Resolver/Exception/UnionTypeDoesNotAllowNull.php b/src/Type/Resolver/Exception/UnionTypeDoesNotAllowNull.php index 8860804..7b7308e 100644 --- a/src/Type/Resolver/Exception/UnionTypeDoesNotAllowNull.php +++ b/src/Type/Resolver/Exception/UnionTypeDoesNotAllowNull.php @@ -4,20 +4,40 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Resolver\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; use CuyZ\Valinor\Type\Types\UnionType; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\TypeHelper; use RuntimeException; /** @api */ -final class UnionTypeDoesNotAllowNull extends RuntimeException implements Message +final class UnionTypeDoesNotAllowNull extends RuntimeException implements TranslatableMessage { + private string $body; + + /** @var array */ + private array $parameters; + public function __construct(UnionType $unionType) { - $message = TypeHelper::containsObject($unionType) - ? 'Cannot be empty.' - : "Cannot be empty and must be filled with a value matching `$unionType`."; + $this->parameters = [ + 'expected_type' => TypeHelper::dump($unionType), + ]; - parent::__construct($message, 1618742357); + $this->body = TypeHelper::containsObject($unionType) + ? 'Cannot be empty.' + : 'Cannot be empty and must be filled with a value matching type {expected_type}.'; + + parent::__construct(StringFormatter::for($this), 1618742357); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/CannotCastValue.php b/src/Type/Types/Exception/CannotCastValue.php index 7a2806b..68ea478 100644 --- a/src/Type/Types/Exception/CannotCastValue.php +++ b/src/Type/Types/Exception/CannotCastValue.php @@ -5,22 +5,39 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; use CuyZ\Valinor\Type\ScalarType; +use CuyZ\Valinor\Utility\String\StringFormatter; +use CuyZ\Valinor\Utility\TypeHelper; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ final class CannotCastValue extends RuntimeException implements CastError { + private string $body = 'Cannot cast {value} to {expected_type}.'; + + /** @var array */ + private array $parameters; + /** * @param mixed $value */ public function __construct($value, ScalarType $type) { - $value = ValueDumper::dump($value); + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'expected_type' => TypeHelper::dump($type), + ]; - parent::__construct( - "Cannot cast $value to `$type`.", - 1603216198 - ); + parent::__construct(StringFormatter::for($this), 1603216198); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/CastError.php b/src/Type/Types/Exception/CastError.php index 7952036..f85ded3 100644 --- a/src/Type/Types/Exception/CastError.php +++ b/src/Type/Types/Exception/CastError.php @@ -4,10 +4,10 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; use Throwable; /** @internal */ -interface CastError extends Throwable, Message +interface CastError extends Throwable, TranslatableMessage { } diff --git a/src/Type/Types/Exception/InvalidClassString.php b/src/Type/Types/Exception/InvalidClassString.php index 5357795..b2173a6 100644 --- a/src/Type/Types/Exception/InvalidClassString.php +++ b/src/Type/Types/Exception/InvalidClassString.php @@ -7,6 +7,7 @@ namespace CuyZ\Valinor\Type\Types\Exception; use CuyZ\Valinor\Type\ObjectType; use CuyZ\Valinor\Type\Type; use CuyZ\Valinor\Type\Types\UnionType; +use CuyZ\Valinor\Utility\String\StringFormatter; use LogicException; use function count; @@ -15,27 +16,51 @@ use function implode; /** @api */ final class InvalidClassString extends LogicException implements CastError { + private string $body; + + /** @var array */ + private array $parameters; + /** * @param ObjectType|UnionType|null $type */ public function __construct(string $raw, ?Type $type) { - $types = []; - if ($type instanceof ObjectType) { - $types = [$type]; + $expected = [$type]; } elseif ($type instanceof UnionType) { - $types = $type->types(); + $expected = $type->types(); + } else { + $expected = []; } - $message = "Invalid class string `$raw`."; + $expectedString = count($expected) !== 0 + ? '`' . implode('`, `', $expected) . '`' + : ''; - if (count($types) > 1) { - $message = "Invalid class string `$raw`, it must be one of `" . implode('`, `', $types) . "`."; - } elseif (count($types) === 1) { - $message = "Invalid class string `$raw`, it must be a subtype of `$type`."; + $this->parameters = [ + 'value' => "`$raw`", + 'expected_class_strings' => $expectedString, + ]; + + if (count($expected) > 1) { + $this->body = 'Invalid class string {value}, it must be one of {expected_class_strings}.'; + } elseif (count($expected) === 1) { + $this->body = 'Invalid class string {value}, it must be a subtype of {expected_class_strings}.'; + } else { + $this->body = 'Invalid class string {value}.'; } - parent::__construct($message, 1608132562); + parent::__construct(StringFormatter::for($this), 1608132562); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidEmptyStringValue.php b/src/Type/Types/Exception/InvalidEmptyStringValue.php index fba3a01..60b4691 100644 --- a/src/Type/Types/Exception/InvalidEmptyStringValue.php +++ b/src/Type/Types/Exception/InvalidEmptyStringValue.php @@ -4,16 +4,26 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use RuntimeException; /** @api */ final class InvalidEmptyStringValue extends RuntimeException implements CastError { + private string $body = 'Cannot be empty and must be filled with a valid string value.'; + public function __construct() { - parent::__construct( - "Cannot be empty and must be filled with a valid string value.", - 1632925312 - ); + parent::__construct(StringFormatter::for($this), 1632925312); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return []; } } diff --git a/src/Type/Types/Exception/InvalidFloatValue.php b/src/Type/Types/Exception/InvalidFloatValue.php index e202e59..408481b 100644 --- a/src/Type/Types/Exception/InvalidFloatValue.php +++ b/src/Type/Types/Exception/InvalidFloatValue.php @@ -4,16 +4,34 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use RuntimeException; /** @api */ final class InvalidFloatValue extends RuntimeException implements CastError { + private string $body = 'Value {value} does not match expected {expected_value}.'; + + /** @var array */ + private array $parameters; + public function __construct(float $value, float $expected) { - parent::__construct( - "Value $value does not match expected $expected.", - 1652110115 - ); + $this->parameters = [ + 'value' => (string)$value, + 'expected_value' => (string)$expected, + ]; + + parent::__construct(StringFormatter::for($this), 1652110115); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidFloatValueType.php b/src/Type/Types/Exception/InvalidFloatValueType.php index 9a80cb2..262a660 100644 --- a/src/Type/Types/Exception/InvalidFloatValueType.php +++ b/src/Type/Types/Exception/InvalidFloatValueType.php @@ -4,22 +4,38 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ final class InvalidFloatValueType extends RuntimeException implements CastError { + private string $body = 'Value {value} does not match float value {expected_value}.'; + + /** @var array */ + private array $parameters; + /** * @param mixed $value */ - public function __construct($value, float $floatValue) + public function __construct($value, float $expected) { - $value = ValueDumper::dump($value); + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'expected_value' => (string)$expected, + ]; - parent::__construct( - "Value $value does not match float value $floatValue.", - 1652110003 - ); + parent::__construct(StringFormatter::for($this), 1652110003); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidIntegerRangeValue.php b/src/Type/Types/Exception/InvalidIntegerRangeValue.php index e459788..daf3f9a 100644 --- a/src/Type/Types/Exception/InvalidIntegerRangeValue.php +++ b/src/Type/Types/Exception/InvalidIntegerRangeValue.php @@ -5,16 +5,35 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; use CuyZ\Valinor\Type\Types\IntegerRangeType; +use CuyZ\Valinor\Utility\String\StringFormatter; use RuntimeException; /** @api */ final class InvalidIntegerRangeValue extends RuntimeException implements CastError { + private string $body = 'Invalid value {value}: it must be an integer between {min} and {max}.'; + + /** @var array */ + private array $parameters; + public function __construct(int $value, IntegerRangeType $type) { - parent::__construct( - "Invalid value $value: it must be an integer between {$type->min()} and {$type->max()}.", - 1638785150 - ); + $this->parameters = [ + 'value' => (string)$value, + 'min' => (string)$type->min(), + 'max' => (string)$type->max(), + ]; + + parent::__construct(StringFormatter::for($this), 1638785150); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidIntegerValue.php b/src/Type/Types/Exception/InvalidIntegerValue.php index 43c94a3..dcd64b1 100644 --- a/src/Type/Types/Exception/InvalidIntegerValue.php +++ b/src/Type/Types/Exception/InvalidIntegerValue.php @@ -4,16 +4,34 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use RuntimeException; /** @api */ final class InvalidIntegerValue extends RuntimeException implements CastError { + private string $body = 'Value {value} does not match expected {expected_value}.'; + + /** @var array */ + private array $parameters; + public function __construct(int $value, int $expected) { - parent::__construct( - "Value $value does not match expected $expected.", - 1631090798 - ); + $this->parameters = [ + 'value' => (string)$value, + 'expected_value' => (string)$expected, + ]; + + parent::__construct(StringFormatter::for($this), 1631090798); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidIntegerValueType.php b/src/Type/Types/Exception/InvalidIntegerValueType.php index eb471cd..06a022f 100644 --- a/src/Type/Types/Exception/InvalidIntegerValueType.php +++ b/src/Type/Types/Exception/InvalidIntegerValueType.php @@ -4,22 +4,38 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ final class InvalidIntegerValueType extends RuntimeException implements CastError { + private string $body = 'Value {value} does not match integer value {expected_value}.'; + + /** @var array */ + private array $parameters; + /** * @param mixed $value */ - public function __construct($value, int $integerValue) + public function __construct($value, int $expected) { - $value = ValueDumper::dump($value); + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'expected_value' => (string)$expected, + ]; - parent::__construct( - "Value $value does not match integer value $integerValue.", - 1631267159 - ); + parent::__construct(StringFormatter::for($this), 1631267159); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidNegativeIntegerValue.php b/src/Type/Types/Exception/InvalidNegativeIntegerValue.php index c4d1079..5515ea5 100644 --- a/src/Type/Types/Exception/InvalidNegativeIntegerValue.php +++ b/src/Type/Types/Exception/InvalidNegativeIntegerValue.php @@ -4,16 +4,33 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use RuntimeException; /** @api */ final class InvalidNegativeIntegerValue extends RuntimeException implements CastError { + private string $body = 'Invalid value {value}: it must be a negative integer.'; + + /** @var array */ + private array $parameters; + public function __construct(int $value) { - parent::__construct( - "Invalid value $value: it must be a negative integer.", - 1632923705 - ); + $this->parameters = [ + 'value' => (string)$value, + ]; + + parent::__construct(StringFormatter::for($this), 1632923705); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidPositiveIntegerValue.php b/src/Type/Types/Exception/InvalidPositiveIntegerValue.php index 9e1fc0b..106ea13 100644 --- a/src/Type/Types/Exception/InvalidPositiveIntegerValue.php +++ b/src/Type/Types/Exception/InvalidPositiveIntegerValue.php @@ -4,16 +4,33 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use RuntimeException; /** @api */ final class InvalidPositiveIntegerValue extends RuntimeException implements CastError { + private string $body = 'Invalid value {value}: it must be a positive integer.'; + + /** @var array */ + private array $parameters; + public function __construct(int $value) { - parent::__construct( - "Invalid value $value: it must be a positive integer.", - 1632923676 - ); + $this->parameters = [ + 'value' => (string)$value, + ]; + + parent::__construct(StringFormatter::for($this), 1632923676); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidStringValue.php b/src/Type/Types/Exception/InvalidStringValue.php index 9765474..b921773 100644 --- a/src/Type/Types/Exception/InvalidStringValue.php +++ b/src/Type/Types/Exception/InvalidStringValue.php @@ -4,20 +4,35 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ final class InvalidStringValue extends RuntimeException implements CastError { + private string $body = 'Value {value} does not match expected {expected_value}.'; + + /** @var array */ + private array $parameters; + public function __construct(string $value, string $expected) { - $value = ValueDumper::dump($value); - $expected = ValueDumper::dump($expected); + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'expected_value' => ValueDumper::dump($expected), + ]; - parent::__construct( - "Value $value does not match expected $expected.", - 1631263740 - ); + parent::__construct(StringFormatter::for($this), 1631263740); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidStringValueType.php b/src/Type/Types/Exception/InvalidStringValueType.php index c781217..fa2cf59 100644 --- a/src/Type/Types/Exception/InvalidStringValueType.php +++ b/src/Type/Types/Exception/InvalidStringValueType.php @@ -4,23 +4,38 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ final class InvalidStringValueType extends RuntimeException implements CastError { + private string $body = 'Value {value} does not match string value {expected_value}.'; + + /** @var array */ + private array $parameters; + /** * @param mixed $value */ - public function __construct($value, string $stringValue) + public function __construct($value, string $expected) { - $value = ValueDumper::dump($value); - $stringValue = ValueDumper::dump($stringValue); + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'expected_value' => ValueDumper::dump($expected), + ]; - parent::__construct( - "Value $value does not match string value $stringValue.", - 1631263954 - ); + parent::__construct(StringFormatter::for($this), 1631263954); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Utility/Polyfill.php b/src/Utility/Polyfill.php index c18516c..26a3af4 100644 --- a/src/Utility/Polyfill.php +++ b/src/Utility/Polyfill.php @@ -4,18 +4,6 @@ declare(strict_types=1); namespace CuyZ\Valinor\Utility; -use __PHP_Incomplete_Class; - -use function class_implements; -use function get_class; -use function get_parent_class; -use function is_array; -use function is_bool; -use function is_float; -use function is_int; -use function is_object; -use function is_string; -use function key; use function strlen; use function strncmp; use function strpos; @@ -62,51 +50,4 @@ final class Polyfill return $needleLength <= strlen($haystack) && 0 === substr_compare($haystack, $needle, -$needleLength); } - - /** - * @PHP8.0 use native function - * - * @param mixed $value - */ - public static function get_debug_type($value): string - { - switch (true) { - case null === $value: - return 'null'; - case is_bool($value): - return 'bool'; - case is_string($value): - return 'string'; - case is_array($value): - return 'array'; - case is_int($value): - return 'int'; - case is_float($value): - return 'float'; - case is_object($value): - break; - case $value instanceof __PHP_Incomplete_Class: // @phpstan-ignore-line - return '__PHP_Incomplete_Class'; - default: - // @phpstan-ignore-next-line - if (null === $type = @get_resource_type($value)) { - return 'unknown'; - } - - if ('Unknown' === $type) { - $type = 'closed'; - } - - return "resource ($type)"; - } - - $class = get_class($value); - - if (false === strpos($class, '@')) { - return $class; - } - - // @phpstan-ignore-next-line - return (get_parent_class($class) ?: key(class_implements($class)) ?: 'class') . '@anonymous'; - } } diff --git a/src/Utility/String/StringFormatter.php b/src/Utility/String/StringFormatter.php new file mode 100644 index 0000000..95126c7 --- /dev/null +++ b/src/Utility/String/StringFormatter.php @@ -0,0 +1,70 @@ + $parameters + */ + public static function format(string $locale, string $body, array $parameters = []): string + { + return class_exists(MessageFormatter::class) + ? self::formatWithIntl($locale, $body, $parameters) + : self::formatWithRegex($body, $parameters); + } + + public static function for(TranslatableMessage $message): string + { + return self::formatWithRegex($message->body(), $message->parameters()); + } + + /** + * @param array $parameters + */ + private static function formatWithIntl(string $locale, string $body, array $parameters): string + { + $message = MessageFormatter::formatMessage($locale, $body, $parameters); + + // @PHP8.0 use throw exception expression + if ($message === false) { + throw new StringFormatterError($body); + } + + return $message; + } + + /** + * @param array $parameters + */ + private static function formatWithRegex(string $body, array $parameters): string + { + $message = $body; + + if (preg_match('/{\s*[^}]*[^}a-z_]+\s*}?/', $body)) { + throw new StringFormatterError($body); + } + + foreach ($parameters as $name => $value) { + $name = preg_quote($name, '/'); + + /** @var string $message */ + $message = preg_replace("/{\s*$name\s*}/", $value, $message); + } + + return $message; + } +} diff --git a/src/Utility/String/StringFormatterError.php b/src/Utility/String/StringFormatterError.php new file mode 100644 index 0000000..418d986 --- /dev/null +++ b/src/Utility/String/StringFormatterError.php @@ -0,0 +1,16 @@ +types() as $subType) { + if ($type instanceof CompositeType) { + foreach ($type->traverse() as $subType) { if (self::containsObject($subType)) { return true; } } } - if ($type instanceof ShapedArrayType) { - foreach ($type->elements() as $element) { - if (self::containsObject($element->type())) { - return true; - } - } - } - return false; } } diff --git a/tests/Fake/Mapper/FakeNode.php b/tests/Fake/Mapper/FakeNode.php index 8f72055..f139828 100644 --- a/tests/Fake/Mapper/FakeNode.php +++ b/tests/Fake/Mapper/FakeNode.php @@ -7,7 +7,6 @@ namespace CuyZ\Valinor\Tests\Fake\Mapper; use CuyZ\Valinor\Definition\Attributes; use CuyZ\Valinor\Mapper\Tree\Message\Message; use CuyZ\Valinor\Mapper\Tree\Node; -use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Tests\Fake\Definition\FakeAttributes; use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeErrorMessage; use CuyZ\Valinor\Tests\Fake\Type\FakeType; @@ -26,7 +25,7 @@ final class FakeNode */ public static function leaf(Type $type, $value): Node { - $shell = Shell::root($type, $value); + $shell = FakeShell::new($type, $value); return Node::leaf($shell, $value); } @@ -37,7 +36,7 @@ final class FakeNode */ public static function branch(array $children, Type $type = null, $value = null): Node { - $shell = Shell::root($type ?? FakeType::permissive(), $value); + $shell = FakeShell::new($type ?? FakeType::permissive(), $value); $nodes = []; foreach ($children as $key => $child) { @@ -65,7 +64,7 @@ final class FakeNode */ public static function error(Throwable $error = null): Node { - $shell = Shell::root(FakeType::permissive(), []); + $shell = FakeShell::new(FakeType::permissive(), []); return Node::error($shell, $error ?? new FakeErrorMessage()); } diff --git a/tests/Fake/Mapper/FakeShell.php b/tests/Fake/Mapper/FakeShell.php new file mode 100644 index 0000000..7ed762c --- /dev/null +++ b/tests/Fake/Mapper/FakeShell.php @@ -0,0 +1,25 @@ +message; + parent::__construct($message, $code); } } diff --git a/tests/Fake/Mapper/Tree/Message/FakeNodeMessage.php b/tests/Fake/Mapper/Tree/Message/FakeNodeMessage.php new file mode 100644 index 0000000..69107d5 --- /dev/null +++ b/tests/Fake/Mapper/Tree/Message/FakeNodeMessage.php @@ -0,0 +1,16 @@ + */ + private array $parameters; + + /** + * @param array $parameters + */ + public function __construct(string $body = 'some message', array $parameters = []) + { + $this->body = $body; + $this->parameters = $parameters; + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; + } + + public function code(): string + { + return '1652902453'; + } + + public function __toString() + { + return "$this->body (toString)"; + } +} diff --git a/tests/Fake/Mapper/Tree/Message/Formatter/FakeMessageFormatter.php b/tests/Fake/Mapper/Tree/Message/Formatter/FakeMessageFormatter.php new file mode 100644 index 0000000..ee47739 --- /dev/null +++ b/tests/Fake/Mapper/Tree/Message/Formatter/FakeMessageFormatter.php @@ -0,0 +1,25 @@ +body = $body; + } + } + + public function format(NodeMessage $message): NodeMessage + { + return $message->withBody($this->body ?? "formatted: {$message->body()}"); + } +} diff --git a/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php b/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php index bc79265..2c9e170 100644 --- a/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php +++ b/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php @@ -290,7 +290,7 @@ final class ConstructorRegistrationMappingTest extends IntegrationTest $error = $exception->node()->messages()[0]; self::assertSame('1642787246', $error->code()); - self::assertSame('Value array (empty) is not accepted.', (string)$error); + self::assertSame('Invalid value array (empty).', (string)$error); } } @@ -299,8 +299,8 @@ final class ConstructorRegistrationMappingTest extends IntegrationTest try { $this->mapperBuilder ->registerConstructor( - fn (string $foo): stdClass => new stdClass(), fn (int $bar, float $baz = 1337.404): stdClass => new stdClass(), + fn (string $foo): stdClass => new stdClass(), ) ->mapper() ->map(stdClass::class, []); diff --git a/tests/Integration/Mapping/InterfaceInferringMappingTest.php b/tests/Integration/Mapping/InterfaceInferringMappingTest.php index 9125098..1c68eb3 100644 --- a/tests/Integration/Mapping/InterfaceInferringMappingTest.php +++ b/tests/Integration/Mapping/InterfaceInferringMappingTest.php @@ -158,7 +158,7 @@ final class InterfaceInferringMappingTest extends IntegrationTest $error = $exception->node()->messages()[0]; self::assertSame('1645283485', $error->code()); - self::assertSame('Invalid value 42, it must be an iterable.', (string)$error); + self::assertSame('Invalid value 42: it must be an array.', (string)$error); } } diff --git a/tests/Integration/Mapping/Message/MessageFormatterTest.php b/tests/Integration/Mapping/Message/MessageFormatterTest.php new file mode 100644 index 0000000..3253f8b --- /dev/null +++ b/tests/Integration/Mapping/Message/MessageFormatterTest.php @@ -0,0 +1,38 @@ +mapperBuilder->mapper()->map('int', 'foo'); + } catch (MappingError $error) { + $formatter = new AggregateMessageFormatter( + new LocaleMessageFormatter('fr'), + new MessageMapFormatter([ + 'Cannot cast {value} to {expected_type}.' => 'New message: {value} / {expected_type}', + ]), + (new TranslationMessageFormatter())->withTranslation( + 'fr', + 'New message: {value} / {expected_type}', + 'Nouveau message : {value} / {expected_type}', + ), + ); + + $message = $formatter->format($error->node()->messages()[0]); + + self::assertSame("Nouveau message : 'foo' / `int`", (string)$message); + } + } +} diff --git a/tests/Integration/Mapping/Object/ArrayValuesMappingTest.php b/tests/Integration/Mapping/Object/ArrayValuesMappingTest.php index 07631e6..02c18be 100644 --- a/tests/Integration/Mapping/Object/ArrayValuesMappingTest.php +++ b/tests/Integration/Mapping/Object/ArrayValuesMappingTest.php @@ -76,7 +76,7 @@ final class ArrayValuesMappingTest extends IntegrationTest $error = $exception->node()->children()['nonEmptyArraysOfStrings']->messages()[0]; self::assertSame('1630678334', $error->code()); - self::assertSame('Value array (empty) does not match expected `non-empty-array`.', (string)$error); + self::assertSame('Value array (empty) does not match type `non-empty-array`.', (string)$error); } } diff --git a/tests/Integration/Mapping/Object/DateTimeMappingTest.php b/tests/Integration/Mapping/Object/DateTimeMappingTest.php index 67f2219..f0508f2 100644 --- a/tests/Integration/Mapping/Object/DateTimeMappingTest.php +++ b/tests/Integration/Mapping/Object/DateTimeMappingTest.php @@ -76,7 +76,7 @@ final class DateTimeMappingTest extends IntegrationTest $error = $exception->node()->children()['dateTime']->messages()[0]; self::assertSame('1630686564', $error->code()); - self::assertSame("Impossible to parse date with value 'invalid datetime'.", (string)$error); + self::assertSame("Value 'invalid datetime' does not match a valid date format.", (string)$error); } } @@ -95,7 +95,7 @@ final class DateTimeMappingTest extends IntegrationTest $error = $exception->node()->children()['dateTime']->messages()[0]; self::assertSame('1630686564', $error->code()); - self::assertSame("Impossible to parse date with value '1337'.", (string)$error); + self::assertSame("Value 1337 does not match a valid date format.", (string)$error); } } diff --git a/tests/Integration/Mapping/Object/ListValuesMappingTest.php b/tests/Integration/Mapping/Object/ListValuesMappingTest.php index 3e22b5d..d52560a 100644 --- a/tests/Integration/Mapping/Object/ListValuesMappingTest.php +++ b/tests/Integration/Mapping/Object/ListValuesMappingTest.php @@ -76,7 +76,7 @@ final class ListValuesMappingTest extends IntegrationTest $error = $exception->node()->children()['nonEmptyListOfStrings']->messages()[0]; self::assertSame('1630678334', $error->code()); - self::assertSame('Value array (empty) does not match expected `non-empty-list`.', (string)$error); + self::assertSame('Value array (empty) does not match type `non-empty-list`.', (string)$error); } } diff --git a/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php b/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php index a6083ac..3a2f02b 100644 --- a/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php +++ b/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php @@ -41,7 +41,7 @@ final class ObjectValuesMappingTest extends IntegrationTest $error = $exception->node()->messages()[0]; self::assertSame('1632903281', $error->code()); - self::assertSame("Value 'foo' does not match `array{object: ?, string: string}`.", (string)$error); + self::assertSame("Value 'foo' does not match type `array{object: ?, string: string}`.", (string)$error); } } } diff --git a/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php b/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php index 44dbb23..bcd6f7c 100644 --- a/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php +++ b/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php @@ -92,7 +92,7 @@ final class ScalarValuesMappingTest extends IntegrationTest $error = $exception->node()->children()['value']->messages()[0]; self::assertSame('1618736242', $error->code()); - self::assertSame('Cannot be empty and must be filled with a value of type `string`.', (string)$error); + self::assertSame('Cannot be empty and must be filled with a value matching type `string`.', (string)$error); } } } diff --git a/tests/Integration/Mapping/VisitorMappingTest.php b/tests/Integration/Mapping/VisitorMappingTest.php index 912286b..9b043f2 100644 --- a/tests/Integration/Mapping/VisitorMappingTest.php +++ b/tests/Integration/Mapping/VisitorMappingTest.php @@ -35,7 +35,7 @@ final class VisitorMappingTest extends IntegrationTest ->mapper() ->map(SimpleObject::class, ['value' => 'foo']); } catch (MappingError $exception) { - self::assertSame((string)$error, (string)$exception->node()->messages()[0]); + self::assertSame('some error message', (string)$exception->node()->messages()[0]); } self::assertSame(['#1', '#2'], $visits); diff --git a/tests/Unit/Mapper/Object/ArgumentsTest.php b/tests/Unit/Mapper/Object/ArgumentsTest.php index d7e57c4..54db9d4 100644 --- a/tests/Unit/Mapper/Object/ArgumentsTest.php +++ b/tests/Unit/Mapper/Object/ArgumentsTest.php @@ -39,6 +39,9 @@ final class ArgumentsTest extends TestCase Argument::optional('someOptionalArgument', $typeD, 'defaultValue') ); - self::assertSame("array{someArgument: $typeA, someArgumentOfObject: ?, someArgumentWithUnionOfObject: ?, someOptionalArgument?: $typeD}", $arguments->signature()); + self::assertSame( + "`array{someArgument: $typeA, someArgumentOfObject: ?, someArgumentWithUnionOfObject: ?, someOptionalArgument?: $typeD}`", + $arguments->signature() + ); } } diff --git a/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php index f89fc55..9022e96 100644 --- a/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php @@ -9,7 +9,7 @@ use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ArrayNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Exception\InvalidTraversableKey; use CuyZ\Valinor\Mapper\Tree\Exception\SourceMustBeIterable; -use CuyZ\Valinor\Mapper\Tree\Shell; +use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; use CuyZ\Valinor\Tests\Fake\Type\FakeType; use CuyZ\Valinor\Type\Types\ArrayKeyType; use CuyZ\Valinor\Type\Types\ArrayType; @@ -20,7 +20,7 @@ final class ArrayNodeBuilderTest extends TestCase { public function test_build_with_null_value_returns_empty_branch_node(): void { - $node = (new RootNodeBuilder(new ArrayNodeBuilder()))->build(Shell::root(ArrayType::native(), null)); + $node = (new RootNodeBuilder(new ArrayNodeBuilder()))->build(FakeShell::new(ArrayType::native())); self::assertSame([], $node->value()); self::assertEmpty($node->children()); @@ -30,29 +30,29 @@ final class ArrayNodeBuilderTest extends TestCase { $this->expectException(AssertionError::class); - (new RootNodeBuilder(new ArrayNodeBuilder()))->build(Shell::root(new FakeType(), [])); + (new RootNodeBuilder(new ArrayNodeBuilder()))->build(FakeShell::new(new FakeType())); } public function test_build_with_invalid_source_throws_exception(): void { $this->expectException(SourceMustBeIterable::class); $this->expectExceptionCode(1618739163); - $this->expectExceptionMessage("Value 'foo' does not match expected `array`."); + $this->expectExceptionMessage("Value 'foo' does not match type `array`."); - (new RootNodeBuilder(new ArrayNodeBuilder()))->build(Shell::root(ArrayType::native(), 'foo')); + (new RootNodeBuilder(new ArrayNodeBuilder()))->build(FakeShell::new(ArrayType::native(), 'foo')); } public function test_invalid_source_key_throws_exception(): void { $this->expectException(InvalidTraversableKey::class); $this->expectExceptionCode(1630946163); - $this->expectExceptionMessage("Invalid key 'foo', it must be of type `int`."); + $this->expectExceptionMessage("Key 'foo' does not match type `int`."); $type = new ArrayType(ArrayKeyType::integer(), NativeStringType::get()); $value = [ 'foo' => 'key is not ok', ]; - (new RootNodeBuilder(new ArrayNodeBuilder()))->build(Shell::root($type, $value)); + (new RootNodeBuilder(new ArrayNodeBuilder()))->build(FakeShell::new($type, $value)); } } diff --git a/tests/Unit/Mapper/Tree/Builder/CasterNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/CasterNodeBuilderTest.php index 7b29c9a..cd77787 100644 --- a/tests/Unit/Mapper/Tree/Builder/CasterNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/CasterNodeBuilderTest.php @@ -7,7 +7,7 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree\Builder; use CuyZ\Valinor\Mapper\Tree\Builder\CasterNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Exception\NoCasterForType; -use CuyZ\Valinor\Mapper\Tree\Shell; +use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; use CuyZ\Valinor\Tests\Fake\Type\FakeType; use PHPUnit\Framework\TestCase; @@ -21,6 +21,6 @@ final class CasterNodeBuilderTest extends TestCase $this->expectExceptionCode(1630693475); $this->expectExceptionMessage("No caster was found to convert to type `$type`."); - (new RootNodeBuilder(new CasterNodeBuilder([])))->build(Shell::root($type, [])); + (new RootNodeBuilder(new CasterNodeBuilder([])))->build(FakeShell::new($type)); } } diff --git a/tests/Unit/Mapper/Tree/Builder/EnumNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/EnumNodeBuilderTest.php index 7a89ec4..14b7d2f 100644 --- a/tests/Unit/Mapper/Tree/Builder/EnumNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/EnumNodeBuilderTest.php @@ -8,8 +8,7 @@ use AssertionError; use CuyZ\Valinor\Mapper\Tree\Builder\EnumNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Exception\InvalidEnumValue; -use CuyZ\Valinor\Mapper\Tree\Shell; -use CuyZ\Valinor\Tests\Fake\Type\FakeType; +use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; use CuyZ\Valinor\Tests\Fixture\Enum\BackedIntegerEnum; use CuyZ\Valinor\Tests\Fixture\Enum\BackedStringEnum; use CuyZ\Valinor\Tests\Fixture\Enum\PureEnum; @@ -35,7 +34,7 @@ final class EnumNodeBuilderTest extends TestCase { $this->expectException(AssertionError::class); - $this->builder->build(Shell::root(new FakeType(), [])); + $this->builder->build(FakeShell::any()); } public function test_invalid_value_throws_exception(): void @@ -44,9 +43,9 @@ final class EnumNodeBuilderTest extends TestCase $this->expectException(InvalidEnumValue::class); $this->expectExceptionCode(1633093113); - $this->expectExceptionMessage("Invalid value 'foo', it must be one of 'FOO', 'BAR'."); + $this->expectExceptionMessage("Value 'foo' does not match any of 'FOO', 'BAR'."); - $this->builder->build(Shell::root($type, 'foo')); + $this->builder->build(FakeShell::new($type, 'foo')); } public function test_invalid_string_value_throws_exception(): void @@ -55,9 +54,9 @@ final class EnumNodeBuilderTest extends TestCase $this->expectException(InvalidEnumValue::class); $this->expectExceptionCode(1633093113); - $this->expectExceptionMessage("Invalid value object(stdClass), it must be one of 'foo', 'bar'."); + $this->expectExceptionMessage("Value object(stdClass) does not match any of 'foo', 'bar'."); - $this->builder->build(Shell::root($type, new stdClass())); + $this->builder->build(FakeShell::new($type, new stdClass())); } public function test_boolean_instead_of_integer_value_throws_exception(): void @@ -66,9 +65,9 @@ final class EnumNodeBuilderTest extends TestCase $this->expectException(InvalidEnumValue::class); $this->expectExceptionCode(1633093113); - $this->expectExceptionMessage('Invalid value false, it must be one of 42, 1337.'); + $this->expectExceptionMessage('Value false does not match any of 42, 1337.'); - $this->builder->build(Shell::root($type, false)); + $this->builder->build(FakeShell::new($type, false)); } public function test_invalid_integer_value_throws_exception(): void @@ -77,8 +76,8 @@ final class EnumNodeBuilderTest extends TestCase $this->expectException(InvalidEnumValue::class); $this->expectExceptionCode(1633093113); - $this->expectExceptionMessage('Invalid value object(stdClass), it must be one of 42, 1337.'); + $this->expectExceptionMessage('Value object(stdClass) does not match any of 42, 1337.'); - $this->builder->build(Shell::root($type, new stdClass())); + $this->builder->build(FakeShell::new($type, new stdClass())); } } diff --git a/tests/Unit/Mapper/Tree/Builder/ListNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/ListNodeBuilderTest.php index 1618d72..38c2225 100644 --- a/tests/Unit/Mapper/Tree/Builder/ListNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/ListNodeBuilderTest.php @@ -8,7 +8,7 @@ use AssertionError; use CuyZ\Valinor\Mapper\Tree\Builder\ListNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Exception\SourceMustBeIterable; -use CuyZ\Valinor\Mapper\Tree\Shell; +use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; use CuyZ\Valinor\Tests\Fake\Type\FakeType; use CuyZ\Valinor\Type\Types\ListType; use PHPUnit\Framework\TestCase; @@ -17,7 +17,7 @@ final class ListNodeBuilderTest extends TestCase { public function test_build_with_null_value_returns_empty_branch_node(): void { - $node = (new RootNodeBuilder(new ListNodeBuilder()))->build(Shell::root(ListType::native(), null)); + $node = (new RootNodeBuilder(new ListNodeBuilder()))->build(FakeShell::new(ListType::native())); self::assertSame([], $node->value()); self::assertEmpty($node->children()); @@ -27,15 +27,15 @@ final class ListNodeBuilderTest extends TestCase { $this->expectException(AssertionError::class); - (new RootNodeBuilder(new ListNodeBuilder()))->build(Shell::root(new FakeType(), [])); + (new RootNodeBuilder(new ListNodeBuilder()))->build(FakeShell::new(new FakeType())); } public function test_build_with_invalid_source_throws_exception(): void { $this->expectException(SourceMustBeIterable::class); $this->expectExceptionCode(1618739163); - $this->expectExceptionMessage("Value 'foo' does not match expected `list`."); + $this->expectExceptionMessage("Value 'foo' does not match type `list`."); - (new RootNodeBuilder(new ListNodeBuilder()))->build(Shell::root(ListType::native(), 'foo')); + (new RootNodeBuilder(new ListNodeBuilder()))->build(FakeShell::new(ListType::native(), 'foo')); } } diff --git a/tests/Unit/Mapper/Tree/Builder/ScalarNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/ScalarNodeBuilderTest.php index 6fb318b..e1785ff 100644 --- a/tests/Unit/Mapper/Tree/Builder/ScalarNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/ScalarNodeBuilderTest.php @@ -7,8 +7,7 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree\Builder; use AssertionError; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ScalarNodeBuilder; -use CuyZ\Valinor\Mapper\Tree\Shell; -use CuyZ\Valinor\Tests\Fake\Type\FakeType; +use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; use PHPUnit\Framework\TestCase; final class ScalarNodeBuilderTest extends TestCase @@ -17,6 +16,6 @@ final class ScalarNodeBuilderTest extends TestCase { $this->expectException(AssertionError::class); - (new RootNodeBuilder(new ScalarNodeBuilder()))->build(Shell::root(new FakeType(), [])); + (new RootNodeBuilder(new ScalarNodeBuilder()))->build(FakeShell::any()); } } diff --git a/tests/Unit/Mapper/Tree/Builder/ShapedArrayNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/ShapedArrayNodeBuilderTest.php index 701a14e..1da2877 100644 --- a/tests/Unit/Mapper/Tree/Builder/ShapedArrayNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/ShapedArrayNodeBuilderTest.php @@ -9,7 +9,7 @@ use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ShapedArrayNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Exception\ShapedArrayElementMissing; use CuyZ\Valinor\Mapper\Tree\Exception\SourceMustBeIterable; -use CuyZ\Valinor\Mapper\Tree\Shell; +use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; use CuyZ\Valinor\Tests\Fake\Type\FakeObjectType; use CuyZ\Valinor\Tests\Fake\Type\FakeType; use CuyZ\Valinor\Type\Types\ShapedArrayElement; @@ -23,7 +23,7 @@ final class ShapedArrayNodeBuilderTest extends TestCase { $this->expectException(AssertionError::class); - (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(Shell::root(new FakeType(), [])); + (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(FakeShell::any()); } public function test_build_with_invalid_source_throws_exception(): void @@ -32,9 +32,9 @@ final class ShapedArrayNodeBuilderTest extends TestCase $this->expectException(SourceMustBeIterable::class); $this->expectExceptionCode(1618739163); - $this->expectExceptionMessage("Value 'foo' does not match expected `array{foo: SomeType}`."); + $this->expectExceptionMessage("Value 'foo' does not match type `array{foo: SomeType}`."); - (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(Shell::root($type, 'foo')); + (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(FakeShell::new($type, 'foo')); } public function test_build_with_invalid_source_for_shaped_array_containing_object_type_throws_exception(): void @@ -43,9 +43,9 @@ final class ShapedArrayNodeBuilderTest extends TestCase $this->expectException(SourceMustBeIterable::class); $this->expectExceptionCode(1618739163); - $this->expectExceptionMessage("Value 'foo' is not accepted."); + $this->expectExceptionMessage("Invalid value 'foo'."); - (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(Shell::root($type, 'foo')); + (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(FakeShell::new($type, 'foo')); } public function test_build_with_null_source_throws_exception(): void @@ -54,19 +54,19 @@ final class ShapedArrayNodeBuilderTest extends TestCase $this->expectException(SourceMustBeIterable::class); $this->expectExceptionCode(1618739163); - $this->expectExceptionMessage("Cannot be empty and must be filled with a value matching `array{foo: SomeType}`."); + $this->expectExceptionMessage("Cannot be empty and must be filled with a value matching type `array{foo: SomeType}`."); - (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(Shell::root($type, null)); + (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(FakeShell::new($type)); } public function test_build_with_missing_key_throws_exception(): void { $this->expectException(ShapedArrayElementMissing::class); $this->expectExceptionCode(1631613641); - $this->expectExceptionMessage("Missing element `foo` of type `SomeType`."); + $this->expectExceptionMessage("Missing element `foo` matching type `SomeType`."); $type = new ShapedArrayType(new ShapedArrayElement(new StringValueType('foo'), new FakeType('SomeType'))); - (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(Shell::root($type, [])); + (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(FakeShell::new($type, [])); } } diff --git a/tests/Unit/Mapper/Tree/Message/Formatter/AggregateMessageFormatterTest.php b/tests/Unit/Mapper/Tree/Message/Formatter/AggregateMessageFormatterTest.php new file mode 100644 index 0000000..b5ff3ec --- /dev/null +++ b/tests/Unit/Mapper/Tree/Message/Formatter/AggregateMessageFormatterTest.php @@ -0,0 +1,25 @@ +format(FakeNodeMessage::any()); + + self::assertSame('message B', (string)$message); + } +} diff --git a/tests/Unit/Mapper/Tree/Message/Formatter/LocaleMessageFormatterTest.php b/tests/Unit/Mapper/Tree/Message/Formatter/LocaleMessageFormatterTest.php new file mode 100644 index 0000000..937dc85 --- /dev/null +++ b/tests/Unit/Mapper/Tree/Message/Formatter/LocaleMessageFormatterTest.php @@ -0,0 +1,20 @@ +format($message); + + self::assertSame('fr', $message->locale()); + } +} diff --git a/tests/Unit/Mapper/Tree/Message/Formatter/MessageMapFormatterTest.php b/tests/Unit/Mapper/Tree/Message/Formatter/MessageMapFormatterTest.php index cf09842..ea129b5 100644 --- a/tests/Unit/Mapper/Tree/Message/Formatter/MessageMapFormatterTest.php +++ b/tests/Unit/Mapper/Tree/Message/Formatter/MessageMapFormatterTest.php @@ -6,65 +6,76 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree\Message\Formatter; use CuyZ\Valinor\Mapper\Tree\Message\Formatter\MessageMapFormatter; use CuyZ\Valinor\Mapper\Tree\Message\NodeMessage; -use CuyZ\Valinor\Tests\Fake\Mapper\FakeNode; use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeMessage; +use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeNodeMessage; use PHPUnit\Framework\TestCase; final class MessageMapFormatterTest extends TestCase { public function test_format_finds_code_returns_formatted_content(): void { - $message = new NodeMessage(FakeNode::any(), new FakeMessage()); - $formatter = new MessageMapFormatter([ - 'some_code' => 'foo', - ]); + $formatter = (new MessageMapFormatter([ + 'some_code' => 'ok', + 'some message' => 'nope', + FakeMessage::class => 'nope', + ]))->defaultsTo('nope'); - self::assertSame('foo', $formatter->format($message)); + $message = $formatter->format(FakeNodeMessage::any()); + + self::assertSame('ok', (string)$message); } public function test_format_finds_code_returns_formatted_content_from_callback(): void { - $message = new NodeMessage(FakeNode::any(), new FakeMessage()); - $formatter = new MessageMapFormatter([ - 'some_code' => fn (NodeMessage $message) => "foo $message", - ]); + $formatter = (new MessageMapFormatter([ + 'some_code' => fn (NodeMessage $message) => "ok $message", + 'some message' => 'nope', + FakeMessage::class => 'nope', + ]))->defaultsTo('nope'); - self::assertSame('foo some message', $formatter->format($message)); + $message = $formatter->format(FakeNodeMessage::any()); + + self::assertSame('ok some message', (string)$message); } - public function test_format_finds_content_returns_formatted_content(): void + public function test_format_finds_body_returns_formatted_content(): void { - $message = new NodeMessage(FakeNode::any(), new FakeMessage()); - $formatter = new MessageMapFormatter([ - 'some message' => 'foo', - ]); + $formatter = (new MessageMapFormatter([ + 'some message' => 'ok', + FakeMessage::class => 'nope', + ]))->defaultsTo('nope'); - self::assertSame('foo', $formatter->format($message)); + $message = $formatter->format(FakeNodeMessage::any()); + + self::assertSame('ok', (string)$message); } public function test_format_finds_class_name_returns_formatted_content(): void { - $message = new NodeMessage(FakeNode::any(), new FakeMessage()); - $formatter = new MessageMapFormatter([ + $formatter = (new MessageMapFormatter([ FakeMessage::class => 'foo', - ]); + ]))->defaultsTo('nope'); - self::assertSame('foo', $formatter->format($message)); + $message = $formatter->format(FakeNodeMessage::any()); + + self::assertSame('foo', (string)$message); } public function test_format_does_not_find_any_returns_default(): void { - $message = new NodeMessage(FakeNode::any(), new FakeMessage()); $formatter = (new MessageMapFormatter([]))->defaultsTo('foo'); - self::assertSame('foo', $formatter->format($message)); + $message = $formatter->format(FakeNodeMessage::any()); + + self::assertSame('foo', (string)$message); } public function test_format_does_not_find_any_returns_message_content(): void { - $message = new NodeMessage(FakeNode::any(), new FakeMessage()); $formatter = new MessageMapFormatter([]); - self::assertSame('some message', $formatter->format($message)); + $message = $formatter->format(FakeNodeMessage::any()); + + self::assertSame('some message', (string)$message); } } diff --git a/tests/Unit/Mapper/Tree/Message/Formatter/PlaceHolderMessageFormatterTest.php b/tests/Unit/Mapper/Tree/Message/Formatter/PlaceHolderMessageFormatterTest.php new file mode 100644 index 0000000..bb0444b --- /dev/null +++ b/tests/Unit/Mapper/Tree/Message/Formatter/PlaceHolderMessageFormatterTest.php @@ -0,0 +1,48 @@ +child('foo', $type, 'some value'); + + $message = new NodeMessage($shell, new FakeMessage('some message')); + $message = (new FakeMessageFormatter('%1$s / %2$s / %3$s / %4$s / %5$s'))->format($message); + $message = (new PlaceHolderMessageFormatter())->format($message); + + self::assertSame("some_code / some message / `$type` / foo / foo", (string)$message); + } + + public function test_format_message_replaces_correct_original_value_if_throwable(): void + { + $message = new NodeMessage(FakeShell::any(), new FakeErrorMessage('some error message')); + $message = (new FakeMessageFormatter('original: %2$s'))->format($message); + $message = (new PlaceHolderMessageFormatter())->format($message); + + self::assertSame('original: some error message', (string)$message); + } + + public function test_format_message_replaces_placeholders_with_given_values(): void + { + $formatter = new PlaceHolderMessageFormatter('foo', 'bar'); + + $message = new NodeMessage(FakeShell::any(), new FakeMessage('%1$s / %2$s')); + $message = $formatter->format($message); + + self::assertSame('foo / bar', (string)$message); + } +} diff --git a/tests/Unit/Mapper/Tree/Message/Formatter/TranslationMessageFormatterTest.php b/tests/Unit/Mapper/Tree/Message/Formatter/TranslationMessageFormatterTest.php new file mode 100644 index 0000000..9f06eae --- /dev/null +++ b/tests/Unit/Mapper/Tree/Message/Formatter/TranslationMessageFormatterTest.php @@ -0,0 +1,132 @@ +withTranslations([ + 'some key' => [ + 'en' => 'some message', + ], + ]); + + $message = new NodeMessage(FakeShell::any(), new FakeMessage('some key')); + $message = $formatter->format($message); + + self::assertSame('some message', (string)$message); + } + + public function test_format_message_with_added_translation_formats_message_correctly(): void + { + $formatter = (new TranslationMessageFormatter())->withTranslations([ + 'some key' => [ + 'en' => 'some message', + ], + ])->withTranslation( + 'en', + 'some key', + 'some other message' + ); + + $message = new NodeMessage(FakeShell::any(), new FakeMessage('some key')); + $message = $formatter->format($message); + + self::assertSame('some other message', (string)$message); + } + + public function test_format_message_with_overridden_translations_formats_message_correctly(): void + { + $formatter = (new TranslationMessageFormatter()) + ->withTranslations([ + 'some key' => [ + 'en' => 'some message', + ], + ])->withTranslations([ + 'some key' => [ + 'en' => 'some other message', + ], + ]); + + $message = new NodeMessage(FakeShell::any(), new FakeMessage('some key')); + $message = $formatter->format($message); + + self::assertSame('some other message', (string)$message); + } + + public function test_format_message_with_overridden_translations_keeps_other_translations(): void + { + $formatter = (new TranslationMessageFormatter()) + ->withTranslations([ + 'some key' => [ + 'en' => 'some message', + ], + 'some other key' => [ + 'en' => 'some other message', + ], + ])->withTranslations([ + 'some key' => [ + 'en' => 'some new message', + ], + ]); + + $message = new NodeMessage(FakeShell::any(), new FakeMessage('some other key')); + $message = $formatter->format($message); + + self::assertSame('some other message', (string)$message); + } + + public function test_format_message_with_default_translations_formats_message_correctly(): void + { + $formatter = TranslationMessageFormatter::default()->withTranslation( + 'en', + 'Value {value} is not accepted.', + 'Value {value} is not accepted!' + ); + + $originalMessage = new FakeTranslatableMessage('Value {value} is not accepted.', ['value' => 'foo']); + $message = new NodeMessage(FakeShell::any(), $originalMessage); + $message = $formatter->format($message); + + self::assertSame('Value foo is not accepted!', (string)$message); + } + + public function test_format_message_with_unknown_translation_returns_same_instance(): void + { + $messageA = FakeNodeMessage::any(); + $messageB = (new TranslationMessageFormatter())->format($messageA); + + self::assertSame($messageA, $messageB); + } + + public function test_with_translation_returns_clone(): void + { + $formatterA = new TranslationMessageFormatter(); + $formatterB = $formatterA->withTranslation('en', 'some message', 'some other message'); + + self::assertNotSame($formatterA, $formatterB); + } + + public function test_with_translations_returns_clone(): void + { + $formatterA = new TranslationMessageFormatter(); + $formatterB = $formatterA->withTranslations([ + 'some message' => [ + 'en' => 'some other message', + ], + ]); + + self::assertNotSame($formatterA, $formatterB); + } +} diff --git a/tests/Unit/Mapper/Tree/Message/NodeMessageTest.php b/tests/Unit/Mapper/Tree/Message/NodeMessageTest.php index 904210c..a4ea6ae 100644 --- a/tests/Unit/Mapper/Tree/Message/NodeMessageTest.php +++ b/tests/Unit/Mapper/Tree/Message/NodeMessageTest.php @@ -4,11 +4,15 @@ declare(strict_types=1); namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree\Message; +use CuyZ\Valinor\Mapper\Tree\Message\Message; use CuyZ\Valinor\Mapper\Tree\Message\NodeMessage; use CuyZ\Valinor\Tests\Fake\Definition\FakeAttributes; -use CuyZ\Valinor\Tests\Fake\Mapper\FakeNode; +use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeErrorMessage; use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeMessage; +use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeNodeMessage; +use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeTranslatableMessage; +use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\Formatter\FakeMessageFormatter; use CuyZ\Valinor\Tests\Fake\Type\FakeType; use PHPUnit\Framework\TestCase; @@ -20,15 +24,8 @@ final class NodeMessageTest extends TestCase $type = FakeType::permissive(); $attributes = new FakeAttributes(); - $node = FakeNode::branch([[ - 'name' => 'foo', - 'type' => $type, - 'value' => 'some value', - 'attributes' => $attributes, - 'message' => $originalMessage - ]])->children()['foo']; - - $message = new NodeMessage($node, $originalMessage); + $shell = FakeShell::any()->child('foo', $type, 'some value', $attributes); + $message = new NodeMessage($shell, $originalMessage); self::assertSame('foo', $message->name()); self::assertSame('foo', $message->path()); @@ -38,42 +35,84 @@ final class NodeMessageTest extends TestCase self::assertSame($originalMessage, $message->originalMessage()); } - public function test_value_from_invalid_node_returns_null(): void + public function test_message_is_error_if_original_message_is_throwable(): void { $originalMessage = new FakeErrorMessage(); - $node = FakeNode::leaf(FakeType::permissive(), 'foo')->withMessage($originalMessage); + $message = new NodeMessage(FakeShell::any(), $originalMessage); - $message = new NodeMessage($node, $originalMessage); - - self::assertNull($message->value()); + self::assertTrue($message->isError()); + self::assertSame('1652883436', $message->code()); + self::assertSame('some error message', $message->body()); } - public function test_format_message_replaces_placeholders_with_default_values(): void + public function test_parameters_are_replaced_in_body(): void { - $originalMessage = new FakeMessage(); + $originalMessage = new FakeTranslatableMessage('some original message', ['some_parameter' => 'some parameter value']); $type = FakeType::permissive(); + $shell = FakeShell::any()->child('foo', $type, 'some value'); - $node = FakeNode::branch([[ - 'name' => 'foo', - 'type' => $type, - 'value' => 'some value', - 'message' => $originalMessage - ]])->children()['foo']; + $message = new NodeMessage($shell, $originalMessage); + $message = $message->withBody('{message_code} / {node_name} / {node_path} / {node_type} / {original_value} / {original_message} / {some_parameter}'); - $message = new NodeMessage($node, $originalMessage); - $text = $message->format('%1$s / %2$s / %3$s / %4$s / %5$s'); - - self::assertSame("some_code / some message / $type / foo / foo", $text); + self::assertSame("1652902453 / foo / foo / `$type` / 'some value' / some original message (toString) / some parameter value", (string)$message); } - public function test_format_message_replaces_placeholders_with_given_values(): void + public function test_replaces_correct_original_message_if_throwable(): void { - $originalMessage = new FakeMessage(); - $node = FakeNode::any(); + $message = new NodeMessage(FakeShell::any(), new FakeErrorMessage('some error message')); + $message = $message->withBody('original: {original_message}'); - $message = new NodeMessage($node, $originalMessage); - $text = $message->format('%1$s / %2$s', 'foo', 'bar'); + self::assertSame('original: some error message', (string)$message); + } - self::assertSame('foo / bar', $text); + public function test_format_message_uses_formatter_to_replace_content(): void + { + $originalMessage = new FakeMessage('some message'); + + $message = new NodeMessage(FakeShell::any(), $originalMessage); + $formattedMessage = (new FakeMessageFormatter())->format($message); + + self::assertNotSame($message, $formattedMessage); + self::assertSame('formatted: some message', (string)$formattedMessage); + } + + public function test_custom_body_returns_clone(): void + { + $messageA = FakeNodeMessage::any(); + $messageB = $messageA->withBody('some other message'); + + self::assertNotSame($messageA, $messageB); + } + + public function test_custom_locale_returns_clone(): void + { + $messageA = FakeNodeMessage::any(); + $messageB = $messageA->withLocale('fr'); + + self::assertNotSame($messageA, $messageB); + } + + public function test_custom_locale_is_used(): void + { + $originalMessage = new FakeTranslatableMessage('un message: {value, spellout}', ['value' => '42']); + + $message = new NodeMessage(FakeShell::any(), $originalMessage); + $message = $message->withLocale('fr'); + + self::assertSame('un message: quarante-deux', (string)$message); + } + + public function test_message_with_no_code_returns_unknown(): void + { + $originalMessage = new class () implements Message { + public function __toString(): string + { + return 'some message'; + } + }; + + $message = new NodeMessage(FakeShell::any(), $originalMessage); + + self::assertSame('unknown', $message->code()); } } diff --git a/tests/Unit/Mapper/Tree/NodeTest.php b/tests/Unit/Mapper/Tree/NodeTest.php index bf46b2f..be3d20a 100644 --- a/tests/Unit/Mapper/Tree/NodeTest.php +++ b/tests/Unit/Mapper/Tree/NodeTest.php @@ -37,7 +37,7 @@ final class NodeTest extends TestCase $this->expectException(InvalidNodeValue::class); $this->expectExceptionCode(1630678334); - $this->expectExceptionMessage("Value 'foo' does not match expected `$type`."); + $this->expectExceptionMessage("Value 'foo' does not match type `$type`."); FakeNode::leaf($type, 'foo'); } @@ -82,7 +82,7 @@ final class NodeTest extends TestCase $this->expectException(InvalidNodeValue::class); $this->expectExceptionCode(1630678334); - $this->expectExceptionMessage("Value 'foo' does not match expected `$type`."); + $this->expectExceptionMessage("Value 'foo' does not match type `$type`."); FakeNode::branch([], $type, 'foo'); } @@ -93,7 +93,7 @@ final class NodeTest extends TestCase $node = FakeNode::error($message); self::assertFalse($node->isValid()); - self::assertSame((string)$message, (string)$node->messages()[0]); + self::assertSame('some error message', (string)$node->messages()[0]); } public function test_get_value_from_invalid_node_throws_exception(): void @@ -133,7 +133,7 @@ final class NodeTest extends TestCase $this->expectException(InvalidNodeValue::class); $this->expectExceptionCode(1630678334); - $this->expectExceptionMessage("Value 1337 does not match expected `$type`."); + $this->expectExceptionMessage("Value 1337 does not match type `$type`."); FakeNode::leaf($type, 'foo')->withValue(1337); } @@ -145,7 +145,7 @@ final class NodeTest extends TestCase $this->expectException(InvalidNodeValue::class); $this->expectExceptionCode(1630678334); - $this->expectExceptionMessage("Value 1337 is not accepted."); + $this->expectExceptionMessage('Invalid value 1337.'); FakeNode::leaf($type, $object)->withValue(1337); } @@ -174,7 +174,7 @@ final class NodeTest extends TestCase self::assertNotSame($nodeA, $nodeB); self::assertFalse($nodeB->isValid()); - self::assertSame((string)$message, (string)$nodeB->messages()[0]); - self::assertSame((string)$errorMessage, (string)$nodeB->messages()[1]); + self::assertSame('some message', (string)$nodeB->messages()[0]); + self::assertSame('some error message', (string)$nodeB->messages()[1]); } } diff --git a/tests/Unit/Type/Resolver/Union/UnionNullNarrowerTest.php b/tests/Unit/Type/Resolver/Union/UnionNullNarrowerTest.php index bddd66a..9e08c6a 100644 --- a/tests/Unit/Type/Resolver/Union/UnionNullNarrowerTest.php +++ b/tests/Unit/Type/Resolver/Union/UnionNullNarrowerTest.php @@ -54,7 +54,7 @@ final class UnionNullNarrowerTest extends TestCase $this->expectException(UnionTypeDoesNotAllowNull::class); $this->expectExceptionCode(1618742357); - $this->expectExceptionMessage("Cannot be empty and must be filled with a value matching `$unionType`."); + $this->expectExceptionMessage("Cannot be empty and must be filled with a value matching type `$unionType`."); $this->unionNullNarrower->narrow($unionType, null); } diff --git a/tests/Unit/Type/Resolver/Union/UnionScalarNarrowerTest.php b/tests/Unit/Type/Resolver/Union/UnionScalarNarrowerTest.php index 95cecb4..aab3feb 100644 --- a/tests/Unit/Type/Resolver/Union/UnionScalarNarrowerTest.php +++ b/tests/Unit/Type/Resolver/Union/UnionScalarNarrowerTest.php @@ -112,7 +112,7 @@ final class UnionScalarNarrowerTest extends TestCase $this->expectException(CannotResolveTypeFromUnion::class); $this->expectExceptionCode(1607027306); - $this->expectExceptionMessage("Value 'foo' is not accepted."); + $this->expectExceptionMessage("Invalid value 'foo'."); $this->unionScalarNarrower->narrow($unionType, 'foo'); } diff --git a/tests/Unit/Utility/String/StringFormatterTest.php b/tests/Unit/Utility/String/StringFormatterTest.php new file mode 100644 index 0000000..39ed8eb --- /dev/null +++ b/tests/Unit/Utility/String/StringFormatterTest.php @@ -0,0 +1,44 @@ +expectException(StringFormatterError::class); + $this->expectExceptionMessage('Message formatter error using `some {wrong.format}`.'); + $this->expectExceptionCode(1652901203); + + StringFormatter::format('en', 'some {wrong.format}', []); + } + + public function test_wrong_message_body_format_throws_exception(): void + { + $this->expectException(StringFormatterError::class); + $this->expectExceptionMessage('Message formatter error using `some message with {invalid format}`.'); + $this->expectExceptionCode(1652901203); + + StringFormatter::for(new FakeTranslatableMessage('some message with {invalid format}')); + } + + public function test_parameters_are_replaced_correctly(): void + { + $message = new FakeTranslatableMessage('some message with {valid_parameter}', [ + 'invalid )( parameter' => 'invalid value', + 'valid_parameter' => 'valid value', + ]); + + self::assertSame('some message with valid value', StringFormatter::for($message)); + } +}