feat!: improve message customization with formatters

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.
This commit is contained in:
Romain Canon 2022-05-18 23:15:08 +02:00
parent 05cf4a4a4d
commit 60a6656141
77 changed files with 1847 additions and 543 deletions

244
README.md
View File

@ -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/

View File

@ -5,6 +5,7 @@
"src"
]
},
"tmpDir": "var/cache/infection",
"logs": {
"text": "var/infection/infection.log",
"summary": "var/infection/summary.log",

View File

@ -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

View File

@ -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;

View File

@ -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<string, string> */
private array $parameters;
/**
* @param mixed $source
* @param non-empty-list<ObjectBuilder> $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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
private array $parameters;
/**
* @param class-string<UnitEnum> $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;
}
}

View File

@ -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;

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Tree\Message;
/** @api */
interface DefaultMessage
{
public const TRANSLATIONS = [
'Value {value} does not match expected {expected_value}.' => [
'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}.',
],
];
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Tree\Message\Formatter;
use CuyZ\Valinor\Mapper\Tree\Message\NodeMessage;
/** @api */
final class AggregateMessageFormatter implements MessageFormatter
{
/** @var MessageFormatter[] */
private array $formatters;
public function __construct(MessageFormatter ...$formatters)
{
$this->formatters = $formatters;
}
public function format(NodeMessage $message): NodeMessage
{
foreach ($this->formatters as $formatter) {
$message = $formatter->format($message);
}
return $message;
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Tree\Message\Formatter;
use CuyZ\Valinor\Mapper\Tree\Message\NodeMessage;
/** @api */
final class LocaleMessageFormatter implements MessageFormatter
{
private string $locale;
public function __construct(string $locale)
{
$this->locale = $locale;
}
public function format(NodeMessage $message): NodeMessage
{
return $message->withLocale($this->locale);
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Tree\Message\Formatter;
use CuyZ\Valinor\Mapper\Tree\Message\NodeMessage;
use CuyZ\Valinor\Utility\TypeHelper;
use Throwable;
/**
* @api
*
* @deprecated This class is here to replace the old implementation of the
* `NodeMessage::format` method; It should not be used anymore
*
* ---
*
* Performs a placeholders replace operation on the message body.
*
* The values to be replaced will be the ones given in the constructor; 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
*/
final class PlaceHolderMessageFormatter implements MessageFormatter
{
/** @var string[] */
private array $values;
public function __construct(string ...$values)
{
$this->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);
}
}

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Tree\Message\Formatter;
use CuyZ\Valinor\Mapper\Tree\Message\DefaultMessage;
use CuyZ\Valinor\Mapper\Tree\Message\NodeMessage;
/** @api */
final class TranslationMessageFormatter implements MessageFormatter
{
/** @var array<string, array<string, string>> */
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<string, array<string, string>> $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;
}
}

View File

@ -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<string, string>
*/
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;
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Tree\Message;
/** @api */
interface TranslatableMessage extends Message
{
public function body(): string;
/**
* @return array<string, string>
*/
public function parameters(): array;
}

View File

@ -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;

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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
{
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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 [];
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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<string, string> */
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;
}
}

View File

@ -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';
}
}

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Utility\String;
use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage;
use MessageFormatter;
use function class_exists;
use function preg_match;
use function preg_quote;
use function preg_replace;
/** @internal */
final class StringFormatter
{
public const DEFAULT_LOCALE = 'en';
/**
* @param array<string, string> $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<string, string> $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<string, string> $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;
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Utility\String;
use RuntimeException;
/** @internal */
final class StringFormatterError extends RuntimeException
{
public function __construct(string $body)
{
parent::__construct("Message formatter error using `$body`.", 1652901203);
}
}

View File

@ -4,36 +4,34 @@ declare(strict_types=1);
namespace CuyZ\Valinor\Utility;
use CuyZ\Valinor\Type\CombiningType;
use CuyZ\Valinor\Type\CompositeType;
use CuyZ\Valinor\Type\ObjectType;
use CuyZ\Valinor\Type\Type;
use CuyZ\Valinor\Type\Types\ShapedArrayType;
/** @internal */
final class TypeHelper
{
public static function dump(Type $type, bool $surround = true): string
{
$text = self::containsObject($type) ? '?' : (string)$type;
return $surround ? "`$text`" : $text;
}
public static function containsObject(Type $type): bool
{
if ($type instanceof ObjectType) {
return true;
}
if ($type instanceof CombiningType) {
foreach ($type->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;
}
}

View File

@ -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());
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Fake\Mapper;
use CuyZ\Valinor\Mapper\Tree\Shell;
use CuyZ\Valinor\Tests\Fake\Type\FakeType;
use CuyZ\Valinor\Type\Type;
final class FakeShell
{
/**
* @param mixed $value
*/
public static function new(Type $type, $value = null): Shell
{
return Shell::root($type, $value);
}
public static function any(): Shell
{
return self::new(new FakeType(), []);
}
}

View File

@ -9,13 +9,8 @@ use Exception;
final class FakeErrorMessage extends Exception implements Message
{
public function __construct(string $message = 'some error message')
public function __construct(string $message = 'some error message', int $code = 1652883436)
{
parent::__construct($message);
}
public function __toString(): string
{
return $this->message;
parent::__construct($message, $code);
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message;
use CuyZ\Valinor\Mapper\Tree\Message\NodeMessage;
use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell;
final class FakeNodeMessage
{
public static function any(): NodeMessage
{
return new NodeMessage(FakeShell::any(), new FakeMessage());
}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message;
use CuyZ\Valinor\Mapper\Tree\Message\HasCode;
use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage;
final class FakeTranslatableMessage implements TranslatableMessage, HasCode
{
private string $body;
/** @var array<string, string> */
private array $parameters;
/**
* @param array<string, string> $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)";
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\Formatter;
use CuyZ\Valinor\Mapper\Tree\Message\Formatter\MessageFormatter;
use CuyZ\Valinor\Mapper\Tree\Message\NodeMessage;
final class FakeMessageFormatter implements MessageFormatter
{
private string $body;
public function __construct(string $body = null)
{
if ($body) {
$this->body = $body;
}
}
public function format(NodeMessage $message): NodeMessage
{
return $message->withBody($this->body ?? "formatted: {$message->body()}");
}
}

View File

@ -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, []);

View File

@ -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);
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping\Message;
use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Mapper\Tree\Message\Formatter\AggregateMessageFormatter;
use CuyZ\Valinor\Mapper\Tree\Message\Formatter\LocaleMessageFormatter;
use CuyZ\Valinor\Mapper\Tree\Message\Formatter\MessageMapFormatter;
use CuyZ\Valinor\Mapper\Tree\Message\Formatter\TranslationMessageFormatter;
use CuyZ\Valinor\Tests\Integration\IntegrationTest;
final class MessageFormatterTest extends IntegrationTest
{
public function test_message_is_formatted_correctly(): void
{
try {
$this->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);
}
}
}

View File

@ -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>`.', (string)$error);
self::assertSame('Value array (empty) does not match type `non-empty-array<string>`.', (string)$error);
}
}

View File

@ -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);
}
}

View File

@ -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>`.', (string)$error);
self::assertSame('Value array (empty) does not match type `non-empty-list<string>`.', (string)$error);
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);

View File

@ -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()
);
}
}

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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()));
}
}

View File

@ -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'));
}
}

View File

@ -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());
}
}

View File

@ -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, []));
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree\Message\Formatter;
use CuyZ\Valinor\Mapper\Tree\Message\Formatter\AggregateMessageFormatter;
use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeNodeMessage;
use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\Formatter\FakeMessageFormatter;
use PHPUnit\Framework\TestCase;
final class AggregateMessageFormatterTest extends TestCase
{
public function test_formatters_are_called_in_correct_order(): void
{
$formatter = new AggregateMessageFormatter(
new FakeMessageFormatter('message A'),
new FakeMessageFormatter('message B'),
);
$message = $formatter->format(FakeNodeMessage::any());
self::assertSame('message B', (string)$message);
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree\Message\Formatter;
use CuyZ\Valinor\Mapper\Tree\Message\Formatter\LocaleMessageFormatter;
use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeNodeMessage;
use PHPUnit\Framework\TestCase;
final class LocaleMessageFormatterTest extends TestCase
{
public function test_locale_is_updated_for_message(): void
{
$message = FakeNodeMessage::any();
$message = (new LocaleMessageFormatter('fr'))->format($message);
self::assertSame('fr', $message->locale());
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree\Message\Formatter;
use CuyZ\Valinor\Mapper\Tree\Message\Formatter\PlaceHolderMessageFormatter;
use CuyZ\Valinor\Mapper\Tree\Message\NodeMessage;
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\Formatter\FakeMessageFormatter;
use CuyZ\Valinor\Tests\Fake\Type\FakeType;
use PHPUnit\Framework\TestCase;
final class PlaceHolderMessageFormatterTest extends TestCase
{
public function test_format_message_replaces_placeholders_with_default_values(): void
{
$type = FakeType::permissive();
$shell = FakeShell::any()->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);
}
}

View File

@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree\Message\Formatter;
use CuyZ\Valinor\Mapper\Tree\Message\Formatter\TranslationMessageFormatter;
use CuyZ\Valinor\Mapper\Tree\Message\NodeMessage;
use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell;
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 PHPUnit\Framework\TestCase;
final class TranslationMessageFormatterTest extends TestCase
{
public function test_format_message_formats_message_correctly(): void
{
$formatter = (new TranslationMessageFormatter())->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);
}
}

View File

@ -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());
}
}

View File

@ -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]);
}
}

View File

@ -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);
}

View File

@ -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');
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Unit\Utility\String;
use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeTranslatableMessage;
use CuyZ\Valinor\Utility\String\StringFormatter;
use CuyZ\Valinor\Utility\String\StringFormatterError;
use PHPUnit\Framework\TestCase;
final class StringFormatterTest extends TestCase
{
/**
* @requires extension intl
*/
public function test_wrong_intl_format_throws_exception(): void
{
$this->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));
}
}