diff --git a/src/Message/Formatter/FirstResultStringFormatter.php b/src/Message/Formatter/FirstResultStringFormatter.php index 47dee6575..da0e65d47 100644 --- a/src/Message/Formatter/FirstResultStringFormatter.php +++ b/src/Message/Formatter/FirstResultStringFormatter.php @@ -18,14 +18,14 @@ final readonly class FirstResultStringFormatter implements StringFormatter { /** @param array $templates */ - public function format(Result $result, Renderer $renderer, array $templates): string + public function format(Result $result, Renderer $renderer, array $templates, bool $isRoot = true): string { if (!$result->hasCustomTemplate()) { foreach ($result->children as $child) { - return $this->format($child, $renderer, $templates); + return $this->format($child, $renderer, $templates, false); } } - return $renderer->render($result, $templates); + return $renderer->render($result, $templates, $isRoot); } } diff --git a/src/Message/Formatter/NestedArrayFormatter.php b/src/Message/Formatter/NestedArrayFormatter.php index e1e8a076d..2cbf16390 100644 --- a/src/Message/Formatter/NestedArrayFormatter.php +++ b/src/Message/Formatter/NestedArrayFormatter.php @@ -28,10 +28,10 @@ * * @return array */ - public function format(Result $result, Renderer $renderer, array $templates): array + public function format(Result $result, Renderer $renderer, array $templates, bool $isRoot = true): array { if ($result->children === []) { - return [$result->path->value ?? $result->id->value => $renderer->render($result, $templates)]; + return [$result->path->value ?? $result->id->value => $renderer->render($result, $templates, $isRoot)]; } $hasStringKey = false; @@ -57,7 +57,7 @@ public function format(Result $result, Renderer $renderer, array $templates): ar } if (count($messages) > 1) { - return ['__root__' => $renderer->render($result, $templates)] + $messages; + return ['__root__' => $renderer->render($result, $templates, $isRoot)] + $messages; } return $messages; @@ -85,12 +85,13 @@ private function formatChild( $hasMultipleChildren && $child->name === $parent->name ? $child->withoutName() : $child, $renderer, $templates, + false, ); $childMessage = count($formatted) === 1 ? current($formatted) : $formatted; if (is_array($childMessage) && count($childMessage) > 1 && !isset($childMessage['__root__'])) { - $childMessage = ['__root__' => $renderer->render($child, $templates)] + $childMessage; + $childMessage = ['__root__' => $renderer->render($child, $templates, false)] + $childMessage; } if (!$hasStringKey) { @@ -147,7 +148,7 @@ private function flattenToIndexedList( Renderer $renderer, array $templates, ): array { - $parentMessage = $messages['__root__'] ?? $renderer->render($parent, $templates); + $parentMessage = $messages['__root__'] ?? $renderer->render($parent, $templates, false); return ['__root__' => $parentMessage] + array_values($messages) + [count($messages) => $childMessage]; } diff --git a/src/Message/Formatter/NestedListStringFormatter.php b/src/Message/Formatter/NestedListStringFormatter.php index 3a9d57836..c95de1f11 100644 --- a/src/Message/Formatter/NestedListStringFormatter.php +++ b/src/Message/Formatter/NestedListStringFormatter.php @@ -29,7 +29,7 @@ /** @param array $templates */ public function format(Result $result, Renderer $renderer, array $templates): string { - return $this->formatRecursively($result, $renderer, $templates, 0, null); + return $this->formatRecursively($result, $renderer, $templates, 0, null, true); } /** @param array $templates */ @@ -39,6 +39,7 @@ private function formatRecursively( array $templates, int $depth, Name|null $lastVisibleName, + bool $isRoot, Result ...$siblings, ): string { $formatted = ''; @@ -50,6 +51,7 @@ private function formatRecursively( $renderer->render( $lastVisibleName === $result->name ? $result->withoutName() : $result, $templates, + $isRoot, ), ); $lastVisibleName ??= $result->name; @@ -63,6 +65,7 @@ private function formatRecursively( $templates, $depth, $lastVisibleName, + false, ...array_filter($result->children, static fn(Result $sibling) => $sibling !== $child), ); $formatted .= PHP_EOL; @@ -77,21 +80,12 @@ private function isVisible(Result $result, Result ...$siblings): bool return true; } - // Parents of an only child are not visible by default if (count($result->children) !== 1) { return true; } - // Only children are always visible - if (count($siblings) === 0) { - return false; - } - - // The visibility of a result then will depend on whether any of its siblings is visible - foreach ($siblings as $key => $currentSibling) { - $otherSiblings = $siblings; - unset($otherSiblings[$key]); - if ($this->isVisible($currentSibling, ...$otherSiblings)) { + foreach ($siblings as $sibling) { + if ($sibling->hasCustomTemplate() || count($sibling->children) !== 1) { return true; } } diff --git a/src/Message/Formatter/TemplateResolver.php b/src/Message/Formatter/TemplateResolver.php index 373f33e8e..b99d6c87d 100644 --- a/src/Message/Formatter/TemplateResolver.php +++ b/src/Message/Formatter/TemplateResolver.php @@ -15,8 +15,6 @@ use Respect\Validation\Path; use Respect\Validation\Result; -use function array_reduce; -use function array_reverse; use function is_array; use function is_string; @@ -28,26 +26,33 @@ public function __construct( } /** @param array $templates */ - public function getGivenTemplate(Result $result, array $templates): string|null + public function getGivenTemplate(Result $result, array $templates, bool $isRoot = true): string|null { if ($result->hasCustomTemplate()) { return $result->template; } + $filtered = $templates; + $isAtCorrectScope = $isRoot; + if ($result->path !== null) { - $templates = $this->filterByPath($result->path, $templates); + [$filtered, $isAtCorrectScope] = $this->filterByPath($result->path, $templates); } - foreach ([$result->path?->value, $result->name?->value, $result->id->value, '__root__'] as $key) { - if ($key === null || !isset($templates[$key])) { + foreach ([$result->path?->value, $result->name?->value, $result->id->value] as $key) { + if ($key === null || !isset($filtered[$key])) { continue; } - if (is_string($templates[$key])) { - return $templates[$key]; + if (is_string($filtered[$key])) { + return $filtered[$key]; } } + if ($isAtCorrectScope && isset($filtered['__root__']) && is_string($filtered['__root__'])) { + return $filtered['__root__']; + } + return null; } @@ -69,31 +74,22 @@ public function getValidatorTemplate(Result $result): string } /** - * @param array $nodes + * @param array $templates * - * @return non-empty-array + * @return array{array, bool} */ - private function getNodes(Path $path, array $nodes = []): array + private function filterByPath(Path $path, array $templates): array { - $nodes[] = $path->value; if ($path->parent !== null) { - return $this->getNodes($path->parent, $nodes); + [$templates, $fullyConsumed] = $this->filterByPath($path->parent, $templates); + } else { + $fullyConsumed = true; } - return $nodes; - } + if (isset($templates[$path->value]) && is_array($templates[$path->value])) { + return [$templates[$path->value], $fullyConsumed]; + } - /** - * @param array $templates - * - * @return array - */ - private function filterByPath(Path $path, array $templates): array - { - return array_reduce( - array_reverse($this->getNodes($path)), - static fn(array $carry, $node) => isset($carry[$node]) && is_array($carry[$node]) ? $carry[$node] : $carry, - $templates, - ); + return [$templates, false]; } } diff --git a/src/Message/InterpolationRenderer.php b/src/Message/InterpolationRenderer.php index cedf4ac29..c20b4a022 100644 --- a/src/Message/InterpolationRenderer.php +++ b/src/Message/InterpolationRenderer.php @@ -26,21 +26,20 @@ public function __construct( } /** @param array $templates */ - public function render(Result $result, array $templates): string + public function render(Result $result, array $templates, bool $isRoot = true): string { $parameters = ['path' => $result->path, 'input' => $result->input, 'subject' => $result]; $parameters += $result->parameters; - $givenTemplate = $this->templateResolver->getGivenTemplate($result, $templates); - $ruleTemplate = $this->templateResolver->getValidatorTemplate($result); + $givenTemplate = $this->templateResolver->getGivenTemplate($result, $templates, $isRoot); $rendered = $this->formatter->formatUsing( - $this->translator->trans($givenTemplate ?? $ruleTemplate), + $this->translator->trans($givenTemplate ?? $this->templateResolver->getValidatorTemplate($result)), $parameters, ); if (!$result->hasCustomTemplate() && $givenTemplate === null && $result->adjacent !== null) { - $rendered .= ' ' . $this->render($result->adjacent, $templates); + $rendered .= ' ' . $this->render($result->adjacent, $templates, false); } return $rendered; diff --git a/src/Message/Renderer.php b/src/Message/Renderer.php index cd57bdc8a..8b936213b 100644 --- a/src/Message/Renderer.php +++ b/src/Message/Renderer.php @@ -16,5 +16,5 @@ interface Renderer { /** @param array $templates */ - public function render(Result $result, array $templates): string; + public function render(Result $result, array $templates, bool $isRoot = true): string; } diff --git a/src/Result.php b/src/Result.php index 9b9560444..bbb171a37 100644 --- a/src/Result.php +++ b/src/Result.php @@ -125,7 +125,12 @@ public function withPrecedentName(bool $hasPrecedentName): self public function withPath(Path $path): self { if ($this->path !== null) { - $this->path->parent = $path; + $current = $this->path; + while ($current->parent !== null) { + $current = $current->parent; + } + + $current->parent = $path; return $this; } diff --git a/tests/feature/Message/Formatter/NestedArrayFormatterTest.php b/tests/feature/Message/Formatter/NestedArrayFormatterTest.php new file mode 100644 index 000000000..6eac14897 --- /dev/null +++ b/tests/feature/Message/Formatter/NestedArrayFormatterTest.php @@ -0,0 +1,205 @@ + + */ + +declare(strict_types=1); + +// String Key Detection + +test('String keys from associative array propagate to messages output', catchAll( + fn() => v::each(v::intType())->assert(['id' => 'John', 'age' => 'thirty']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.id` must be an integer') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Each item in `["id": "John", "age": "thirty"]` must be valid + - `.id` must be an integer + - `.age` must be an integer + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Each item in `["id": "John", "age": "thirty"]` must be valid', + 'id' => '`.id` must be an integer', + 'age' => '`.age` must be an integer', + ]), +)); + +test('Sparse failures renumber to sequential keys in messages output', catchAll( + fn() => v::each(v::intType())->assert([1, 'two', 3, 'four']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.1` must be an integer') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Each item in `[1, "two", 3, "four"]` must be valid + - `.1` must be an integer + - `.3` must be an integer + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Each item in `[1, "two", 3, "four"]` must be valid', + 0 => '`.1` must be an integer', + 1 => '`.3` must be an integer', + ]), +)); + +// __root__ Key and Nesting + +test('__root__ key omitted when only one child message exists', catchAll( + fn() => v::init()->key('email', v::email())->assert(['email' => 'bad']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.email` must be an email address') + ->and($fullMessage)->toBe('- `.email` must be an email address') + ->and($messages)->toBe(['email' => '`.email` must be an email address']), +)); + +test('__root__ placed at each nesting level with multiple children', catchAll( + fn() => v::init() + ->key('database', v::init()->key('host', v::stringType())->key('port', v::intType())) + ->assert(['database' => ['host' => 123, 'port' => 'wrong']]), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.database.host` must be a string') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - `.database` must pass all the rules + - `.database.host` must be a string + - `.database.port` must be an integer + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => '`.database` must pass all the rules', + 'host' => '`.database.host` must be a string', + 'port' => '`.database.port` must be an integer', + ]), +)); + +test('Cross-nesting flattens single-child paths in messages output', catchAll( + fn() => v::init() + ->key('database', v::init()->key('host', v::stringType())) + ->key('cache', v::init()->key('driver', v::in(['redis', 'memcached']))) + ->assert(['database' => ['host' => false], 'cache' => ['driver' => 'sqlite']]), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.database.host` must be a string') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - `["database": ["host": false], "cache": ["driver": "sqlite"]]` must pass all the rules + - `.database.host` must be a string + - `.cache.driver` must be in `["redis", "memcached"]` + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => '`["database": ["host": false], "cache": ["driver": "sqlite"]]` must pass all the rules', + 'host' => '`.database.host` must be a string', + 'driver' => '`.cache.driver` must be in `["redis", "memcached"]`', + ]), +)); + +test('mergeWithExistingPath creates indexed array for same-path collisions', catchAll( + fn() => v::init() + ->key('password', v::stringType()->alnum()->lengthBetween(8, 100)) + ->assert(['password' => '']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.password` must consist only of letters (a-z) and digits (0-9)') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - `.password` must pass the rules + - `.password` must consist only of letters (a-z) and digits (0-9) + - The length of `.password` must be between 8 and 100 + FULL_MESSAGE) + ->and($messages)->toBe([ + 'password' => [ + 0 => '`.password` must consist only of letters (a-z) and digits (0-9)', + 1 => 'The length of `.password` must be between 8 and 100', + ], + ]), +)); + +test('Mixed key presence and value failures produce separate keyed messages', catchAll( + fn() => v::init() + ->key('email', v::email()) + ->key('name', v::stringType()) + ->assert(['email' => 'invalid-email']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.email` must be an email address') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - `["email": "invalid-email"]` must pass all the rules + - `.email` must be an email address + - `.name` must be present + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => '`["email": "invalid-email"]` must pass all the rules', + 'email' => '`.email` must be an email address', + 'name' => '`.name` must be present', + ]), +)); + +// Deep Nesting + +test('Four-level nesting produces correct full paths in messages', catchAll( + fn() => v::init() + ->key('app', v::init() + ->key('server', v::init() + ->key('database', v::init() + ->key('host', v::stringType()) + ->key('port', v::intType())))) + ->assert(['app' => ['server' => ['database' => ['host' => false, 'port' => 'bad']]]]), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.app.server.database.host` must be a string') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - `.app.server.database` must pass all the rules + - `.app.server.database.host` must be a string + - `.app.server.database.port` must be an integer + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => '`.app.server.database` must pass all the rules', + 'host' => '`.app.server.database.host` must be a string', + 'port' => '`.app.server.database.port` must be an integer', + ]), +)); + +test('Shallow and deep siblings produce nested array for deep branch', catchAll( + fn() => v::init() + ->key('name', v::alpha()) + ->key('config', v::init() + ->key('db', v::init() + ->key('host', v::stringType()) + ->key('port', v::intType()))) + ->assert(['name' => '123', 'config' => ['db' => ['host' => false, 'port' => 'x']]]), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.name` must consist only of letters (a-z)') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - `["name": "123", "config": ["db": ["host": false, "port": "x"]]]` must pass all the rules + - `.name` must consist only of letters (a-z) + - `.config.db` must pass all the rules + - `.config.db.host` must be a string + - `.config.db.port` must be an integer + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => '`["name": "123", "config": ["db": ["host": false, "port": "x"]]]` must pass all the rules', + 'name' => '`.name` must consist only of letters (a-z)', + 'db' => [ + '__root__' => '`.config.db` must pass all the rules', + 'host' => '`.config.db.host` must be a string', + 'port' => '`.config.db.port` must be an integer', + ], + ]), +)); + +// Each with Nested Rules + +test('String-keyed items with multi-failure children produce nested message arrays', catchAll( + fn() => v::each(v::intType()->positive())->assert(['x' => -1, 'y' => 'abc']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.x` must be a positive number') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Each item in `["x": -1, "y": "abc"]` must be valid + - `.x` must pass the rules + - `.x` must be a positive number + - `.y` must pass all the rules + - `.y` must be an integer + - `.y` must be a positive number + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Each item in `["x": -1, "y": "abc"]` must be valid', + 'x' => '`.x` must be a positive number', + 'y' => [ + '__root__' => '`.y` must pass all the rules', + 0 => '`.y` must be an integer', + 1 => '`.y` must be a positive number', + ], + ]), +)); diff --git a/tests/feature/Message/Formatter/NestedListStringFormatterTest.php b/tests/feature/Message/Formatter/NestedListStringFormatterTest.php new file mode 100644 index 000000000..d5034b203 --- /dev/null +++ b/tests/feature/Message/Formatter/NestedListStringFormatterTest.php @@ -0,0 +1,156 @@ + + */ + +declare(strict_types=1); + +// Orphan Collapsing + +test('Deep single-child chain collapses entirely to the leaf error', catchAll( + fn() => v::init()->key('email', v::email())->assert(['email' => 'not-an-email']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.email` must be an email address') + ->and($fullMessage)->toBe('- `.email` must be an email address') + ->and($messages)->toBe(['email' => '`.email` must be an email address']), +)); + +test('Three levels of nested keys collapse to the leaf error', catchAll( + fn() => v::init() + ->key('server', v::init() + ->key('database', v::init() + ->key('host', v::stringType()))) + ->assert(['server' => ['database' => ['host' => 123]]]), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.server.database.host` must be a string') + ->and($fullMessage)->toBe('- `.server.database.host` must be a string') + ->and($messages)->toBe(['host' => '`.server.database.host` must be a string']), +)); + +test('Single-child siblings all collapse when none has multiple children', catchAll( + fn() => v::init() + ->key('email', v::email()) + ->key('age', v::intType()) + ->assert(['email' => 'bad', 'age' => 'old']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.email` must be an email address') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - `["email": "bad", "age": "old"]` must pass all the rules + - `.email` must be an email address + - `.age` must be an integer + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => '`["email": "bad", "age": "old"]` must pass all the rules', + 'email' => '`.email` must be an email address', + 'age' => '`.age` must be an integer', + ]), +)); + +test('Sibling with multiple failures forces single-child sibling to become visible', catchAll( + fn() => v::init() + ->key('username', v::alnum()->lengthBetween(3, 20)) + ->key('email', v::email()) + ->assert(['username' => '!!', 'email' => 'invalid']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.username` must consist only of letters (a-z) and digits (0-9)') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - `["username": "!!", "email": "invalid"]` must pass all the rules + - `.username` must pass all the rules + - `.username` must consist only of letters (a-z) and digits (0-9) + - The length of `.username` must be between 3 and 20 + - `.email` must be an email address + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => '`["username": "!!", "email": "invalid"]` must pass all the rules', + 'username' => [ + '__root__' => '`.username` must pass all the rules', + 0 => '`.username` must consist only of letters (a-z) and digits (0-9)', + 1 => 'The length of `.username` must be between 3 and 20', + ], + 'email' => '`.email` must be an email address', + ]), +)); + +test('Custom template on wrapper prevents collapsing even with single child', catchAll( + fn() => v::templated('Data validation failed', v::init() + ->key('email', v::email()) + ->key('age', v::intType())) + ->assert(['email' => 'bad', 'age' => 'old']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Data validation failed') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Data validation failed + - `.email` must be an email address + - `.age` must be an integer + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Data validation failed', + 'email' => '`.email` must be an email address', + 'age' => '`.age` must be an integer', + ]), +)); + +// Deep Nesting + +test('Four levels of single-child nesting collapse entirely', catchAll( + fn() => v::init() + ->key('app', v::init() + ->key('server', v::init() + ->key('database', v::init() + ->key('host', v::stringType())))) + ->assert(['app' => ['server' => ['database' => ['host' => false]]]]), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.app.server.database.host` must be a string') + ->and($fullMessage)->toBe('- `.app.server.database.host` must be a string') + ->and($messages)->toBe(['host' => '`.app.server.database.host` must be a string']), +)); + +test('Mixed depth siblings render at different levels in full message', catchAll( + fn() => v::init() + ->key('name', v::alpha()) + ->key('server', v::init() + ->key('database', v::init() + ->key('host', v::stringType()))) + ->assert(['name' => '123', 'server' => ['database' => ['host' => false]]]), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.name` must consist only of letters (a-z)') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - `["name": "123", "server": ["database": ["host": false]]]` must pass all the rules + - `.name` must consist only of letters (a-z) + - `.server.database.host` must be a string + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => '`["name": "123", "server": ["database": ["host": false]]]` must pass all the rules', + 'name' => '`.name` must consist only of letters (a-z)', + 'host' => '`.server.database.host` must be a string', + ]), +)); + +// Name Deduplication + +test('Named wrapper shows name once, children use raw input instead', catchAll( + fn() => v::named('User Age', v::intType()->positive())->assert('old'), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('User Age must be an integer') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - User Age must pass all the rules + - "old" must be an integer + - "old" must be a positive number + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'User Age must pass all the rules', + 'intType' => '"old" must be an integer', + 'positive' => '"old" must be a positive number', + ]), +)); + +test('Differently named inner validator preserves its own name', catchAll( + fn() => v::named('Registration', v::init()->key('email', v::named('Email Address', v::email())))->assert(['email' => 'bad']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Email Address must be an email address') + ->and($fullMessage)->toBe('- Email Address must be an email address') + ->and($messages)->toBe(['email' => 'Email Address must be an email address']), +)); diff --git a/tests/feature/Message/Formatter/TemplateResolverTest.php b/tests/feature/Message/Formatter/TemplateResolverTest.php new file mode 100644 index 000000000..f576ee198 --- /dev/null +++ b/tests/feature/Message/Formatter/TemplateResolverTest.php @@ -0,0 +1,310 @@ + + */ + +declare(strict_types=1); + +test('Path-based templates override default messages for each key', catchAll( + fn() => v::init() + ->key('email', v::email()) + ->key('age', v::intType()) + ->assert( + ['email' => 'bad', 'age' => 'young'], + ['email' => 'Please enter a valid email', 'age' => 'Age must be a number'], + ), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Please enter a valid email') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - `["email": "bad", "age": "young"]` must pass all the rules + - Please enter a valid email + - Age must be a number + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => '`["email": "bad", "age": "young"]` must pass all the rules', + 'email' => 'Please enter a valid email', + 'age' => 'Age must be a number', + ]), +)); + +test('Nested path-based templates resolve through key hierarchy', catchAll( + fn() => v::init() + ->key('address', v::init()->key('zip', v::digit())) + ->assert( + ['address' => ['zip' => 'invalid']], + ['address' => ['zip' => 'Please enter a valid ZIP code']], + ), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Please enter a valid ZIP code') + ->and($fullMessage)->toBe('- Please enter a valid ZIP code') + ->and($messages)->toBe(['zip' => 'Please enter a valid ZIP code']), +)); + +test('__root__ template overrides only the parent composite message', catchAll( + fn() => v::init() + ->key('email', v::email()) + ->key('age', v::intType()) + ->assert( + ['email' => 'bad', 'age' => 'old'], + ['__root__' => 'Registration data is invalid'], + ), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.email` must be an email address') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Registration data is invalid + - `.email` must be an email address + - `.age` must be an integer + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Registration data is invalid', + 'email' => '`.email` must be an email address', + 'age' => '`.age` must be an integer', + ]), +)); + +test('Nested __root__ targets only the inner composite, not its children', catchAll( + fn() => v::init() + ->key('address', v::init()->key('zip', v::digit())->key('city', v::alpha())) + ->assert( + ['address' => ['zip' => 'bad', 'city' => '123']], + ['address' => ['__root__' => 'Address section has errors']], + ), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.address.zip` must consist only of digits (0-9)') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Address section has errors + - `.address.zip` must consist only of digits (0-9) + - `.address.city` must consist only of letters (a-z) + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Address section has errors', + 'zip' => '`.address.zip` must consist only of digits (0-9)', + 'city' => '`.address.city` must consist only of letters (a-z)', + ]), +)); + +test('__root__ does not cascade to non-path children in flat AllOf', catchAll( + fn() => v::allOf(v::stringType(), v::arrayType())->assert(5, ['__root__' => 'Input is invalid']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('5 must be a string') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Input is invalid + - 5 must be a string + - 5 must be an array + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Input is invalid', + 'stringType' => '5 must be a string', + 'arrayType' => '5 must be an array', + ]), +)); + +test('__root__ with partial child templates leaves unmatched children at defaults', catchAll( + fn() => v::init() + ->key('email', v::email()) + ->key('name', v::stringType()) + ->key('age', v::intType()) + ->assert( + ['email' => 'bad', 'name' => 123, 'age' => 'old'], + ['__root__' => 'Form validation failed', 'email' => 'Please provide a valid email'], + ), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Please provide a valid email') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Form validation failed + - Please provide a valid email + - `.name` must be a string + - `.age` must be an integer + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Form validation failed', + 'email' => 'Please provide a valid email', + 'name' => '`.name` must be a string', + 'age' => '`.age` must be an integer', + ]), +)); + +test('Template with {{subject}} placeholder renders the path', catchAll( + fn() => v::init() + ->key('email', v::email()) + ->assert( + ['email' => 'bad'], + ['email' => '{{subject}} is not a valid email address'], + ), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.email` is not a valid email address') + ->and($fullMessage)->toBe('- `.email` is not a valid email address') + ->and($messages)->toBe(['email' => '`.email` is not a valid email address']), +)); + +test('Three-level deep template resolves to the leaf path', catchAll( + fn() => v::init() + ->key('server', v::init() + ->key('database', v::init() + ->key('host', v::stringType()))) + ->assert( + ['server' => ['database' => ['host' => 123]]], + ['server' => ['database' => ['host' => 'Must be a hostname string']]], + ), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Must be a hostname string') + ->and($fullMessage)->toBe('- Must be a hostname string') + ->and($messages)->toBe(['host' => 'Must be a hostname string']), +)); + +test('String template at intermediate path overrides that composite only', catchAll( + fn() => v::init() + ->key('server', v::init() + ->key('database', v::init() + ->key('host', v::stringType()) + ->key('port', v::intType()))) + ->assert( + ['server' => ['database' => ['host' => 123, 'port' => 'wrong']]], + ['server' => ['database' => 'Database configuration is invalid']], + ), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.server.database.host` must be a string') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Database configuration is invalid + - `.server.database.host` must be a string + - `.server.database.port` must be an integer + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Database configuration is invalid', + 'host' => '`.server.database.host` must be a string', + 'port' => '`.server.database.port` must be an integer', + ]), +)); + +test('Partial deep templates override matched children, siblings get defaults', catchAll( + fn() => v::init() + ->key('server', v::init() + ->key('database', v::init() + ->key('host', v::stringType()) + ->key('port', v::intType()))) + ->assert( + ['server' => ['database' => ['host' => 123, 'port' => 'wrong']]], + ['server' => ['database' => ['host' => 'Host must be a string']]], + ), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Host must be a string') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - `.server.database` must pass all the rules + - Host must be a string + - `.server.database.port` must be an integer + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => '`.server.database` must pass all the rules', + 'host' => 'Host must be a string', + 'port' => '`.server.database.port` must be an integer', + ]), +)); + +test('Deep templates for all siblings at the same level', catchAll( + fn() => v::init() + ->key('server', v::init() + ->key('database', v::init() + ->key('host', v::stringType()) + ->key('port', v::intType()))) + ->assert( + ['server' => ['database' => ['host' => 123, 'port' => 'bad']]], + ['server' => ['database' => ['host' => 'Host must be a string', 'port' => 'Port must be an integer']]], + ), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Host must be a string') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - `.server.database` must pass all the rules + - Host must be a string + - Port must be an integer + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => '`.server.database` must pass all the rules', + 'host' => 'Host must be a string', + 'port' => 'Port must be an integer', + ]), +)); + +test('__root__ at multiple nesting levels targets each composite independently', catchAll( + fn() => v::init() + ->key('email', v::email()) + ->key('address', v::init()->key('zip', v::digit())->key('city', v::alpha())) + ->assert( + ['email' => 'bad', 'address' => ['zip' => '!!', 'city' => '123']], + ['__root__' => 'Validation failed', 'address' => ['__root__' => 'Address is invalid']], + ), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.email` must be an email address') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Validation failed + - `.email` must be an email address + - Address is invalid + - `.address.zip` must consist only of digits (0-9) + - `.address.city` must consist only of letters (a-z) + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Validation failed', + 'email' => '`.email` must be an email address', + 'address' => [ + '__root__' => 'Address is invalid', + 'zip' => '`.address.zip` must consist only of digits (0-9)', + 'city' => '`.address.city` must consist only of letters (a-z)', + ], + ]), +)); + +test('__root__ at deepest nested level scopes to that composite only', catchAll( + fn() => v::init() + ->key('server', v::init() + ->key('database', v::init() + ->key('host', v::stringType()) + ->key('port', v::intType()))) + ->assert( + ['server' => ['database' => ['host' => 123, 'port' => 'bad']]], + ['server' => ['database' => ['__root__' => 'Database config is invalid']]], + ), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.server.database.host` must be a string') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - Database config is invalid + - `.server.database.host` must be a string + - `.server.database.port` must be an integer + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => 'Database config is invalid', + 'host' => '`.server.database.host` must be a string', + 'port' => '`.server.database.port` must be an integer', + ]), +)); + +test('{{subject}} placeholder resolves at three-level deep path', catchAll( + fn() => v::init() + ->key('server', v::init() + ->key('database', v::init() + ->key('host', v::stringType()))) + ->assert( + ['server' => ['database' => ['host' => 123]]], + ['server' => ['database' => ['host' => '{{subject}} is not a valid hostname']]], + ), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.server.database.host` is not a valid hostname') + ->and($fullMessage)->toBe('- `.server.database.host` is not a valid hostname') + ->and($messages)->toBe(['host' => '`.server.database.host` is not a valid hostname']), +)); + +test('Flat template key matches by leaf path value regardless of nesting depth', catchAll( + fn() => v::init() + ->key('server', v::init() + ->key('database', v::init() + ->key('host', v::stringType()))) + ->assert( + ['server' => ['database' => ['host' => 123]]], + ['host' => 'Hostname must be a string'], + ), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Hostname must be a string') + ->and($fullMessage)->toBe('- Hostname must be a string') + ->and($messages)->toBe(['host' => 'Hostname must be a string']), +)); diff --git a/tests/feature/Message/InterpolationRendererTest.php b/tests/feature/Message/InterpolationRendererTest.php new file mode 100644 index 000000000..06594e4b7 --- /dev/null +++ b/tests/feature/Message/InterpolationRendererTest.php @@ -0,0 +1,25 @@ + + */ + +declare(strict_types=1); + +test('givenTemplate suppresses adjacent result concatenation', catchAll( + fn() => v::templated('Password is too short', v::lengthBetween(8, 64))->assert('hi'), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('Password is too short') + ->and($fullMessage)->toBe('- Password is too short') + ->and($messages)->toBe(['lengthBetween' => 'Password is too short']), +)); + +test('Adjacent result renders path-aware subject in compound message', catchAll( + fn() => v::init()->key('password', v::lengthBetween(8, 64))->assert(['password' => 'hi']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('The length of `.password` must be between 8 and 64') + ->and($fullMessage)->toBe('- The length of `.password` must be between 8 and 64') + ->and($messages)->toBe(['password' => 'The length of `.password` must be between 8 and 64']), +)); diff --git a/tests/feature/ResultQueryTest.php b/tests/feature/ResultQueryTest.php index 8b1a1f2b2..fb7b4eb66 100644 --- a/tests/feature/ResultQueryTest.php +++ b/tests/feature/ResultQueryTest.php @@ -126,7 +126,7 @@ $firstMatch = $result->findByPath('groups.*.*'); - expect($firstMatch?->getMessage())->toBe('`.groups.items.1` must be a string'); + expect($firstMatch?->getMessage())->toBe('`.groups.0.items.1` must be a string'); }); test('findByPath with wildcard returns null when no matches', function (): void { diff --git a/tests/src/Message/TestingMessageRenderer.php b/tests/src/Message/TestingMessageRenderer.php index 6790d9acb..73501063f 100644 --- a/tests/src/Message/TestingMessageRenderer.php +++ b/tests/src/Message/TestingMessageRenderer.php @@ -17,7 +17,7 @@ final class TestingMessageRenderer implements Renderer { /** @param array $templates */ - public function render(Result $result, array $templates): string + public function render(Result $result, array $templates, bool $isRoot = true): string { return $result->template; }