From 9bab4dc10eb6e2ea2c69fe623dfd581d82ec1599 Mon Sep 17 00:00:00 2001 From: Arne Blankerts Date: Fri, 28 Jul 2023 16:08:50 +0200 Subject: [PATCH] Better Error reporting, be more specific in error testing --- src/viewmodel/ViewModelRenderer.php | 153 ++++++++++++++++--- src/viewmodel/ViewModelRendererException.php | 2 +- tests/viewmodel/ViewModelRendererTest.php | 48 ++++++ 3 files changed, 182 insertions(+), 21 deletions(-) diff --git a/src/viewmodel/ViewModelRenderer.php b/src/viewmodel/ViewModelRenderer.php index 87adf64..8d80747 100644 --- a/src/viewmodel/ViewModelRenderer.php +++ b/src/viewmodel/ViewModelRenderer.php @@ -10,6 +10,7 @@ namespace Templado\Engine; use function array_key_exists; +use function array_reverse; use function gettype; use function is_iterable; use function is_object; @@ -104,7 +105,7 @@ private function registerPrefix(string $prefixString): void { if (count($parts) !== 2) { throw new ViewModelRendererException( - 'Invalid prefix definition', + sprintf('Invalid prefix definition "%s"', $prefixString), ViewModelRendererException::InvalidPrefixDefinition ); } @@ -128,14 +129,24 @@ private function registerPrefix(string $prefixString): void { method_exists($this->rootModel, '__get') => $this->rootModel->{$method}, default => throw new ViewModelRendererException( - sprintf('Cannot resolve prefix request for "%s"', $method), + $this->buildExceptionMessage( + null, + $this->rootModel, + $method, + sprintf('Cannot resolve prefix request for "%s"', $method) + ), ViewModelRendererException::PrefixResolvingFailed ) }; if (!is_object($result)) { throw new ViewModelRendererException( - 'Prefix type must be an object', + $this->buildExceptionMessage( + null, + $this->rootModel, + $method, + 'Prefix type must be an object' + ), ViewModelRendererException::WrongTypeForPrefix ); } @@ -169,14 +180,24 @@ private function resolveResource(string $resource): object { method_exists($model, '__get') => $model->{$resource}, default => throw new ViewModelRendererException( - sprintf('Cannot resolve resource request for "%s"', $resource), + $this->buildExceptionMessage( + null, + $this->rootModel, + $resource, + 'Cannot resolve resource request' + ), ViewModelRendererException::ResourceResolvingFailed ) }; if (!is_object($result)) { throw new ViewModelRendererException( - 'Resouce type must be a object', + $this->buildExceptionMessage( + null, + $this->rootModel, + $resource, + sprintf('Resouce type "%s" not supported - must be a object', gettype($result)) + ), ViewModelRendererException::WrongTypeForResource ); } @@ -187,7 +208,7 @@ private function resolveResource(string $resource): object { private function modelForPrefix(string $prefix): ?object { if (!array_key_exists($prefix, $this->prefixModels)) { throw new ViewModelRendererException( - 'No model set for prefix', + sprintf('No model set for prefix "%s"', $prefix), ViewModelRendererException::NoModelForPrefix ); } @@ -211,7 +232,12 @@ private function modelSupportsVocab(object $model, string $requiredVocab): void if (!is_string($modelVocab)) { throw new ViewModelRendererException( - 'Result of vocab query must be of type string', + $this->buildExceptionMessage( + null, + $model, + 'vocab', + sprintf('Unsupported type "%s" - result of vocab query must be of type string', gettype($modelVocab)) + ), ViewModelRendererException::WrongTypeForVocab ); } @@ -258,7 +284,12 @@ private function processProperty(DOMElement $context, object $model): object { method_exists($model, '__get') => $model->{$property}, default => throw new ViewModelRendererException( - sprintf('Cannot resolve property request for "%s"', $property), + $this->buildExceptionMessage( + $context, + $model, + $property, + 'No accessor for property' + ), ViewModelRendererException::ResolvingPropertyFailed ) }; @@ -266,7 +297,12 @@ private function processProperty(DOMElement $context, object $model): object { if ($context->hasAttribute('typeof')) { if (!is_iterable($result) && !is_object($result)) { throw new ViewModelRendererException( - 'TypeOf handling requires object / list of objects', + $this->buildExceptionMessage( + $context, + $model, + $property, + sprintf('Unsupported type "%s" - typeOf handling requires object / list of objects', gettype($result)) + ), ViewModelRendererException::WrongTypeForTypeOf ); } @@ -306,7 +342,12 @@ private function processProperty(DOMElement $context, object $model): object { } throw new ViewModelRendererException( - 'Unsupported type', + $this->buildExceptionMessage( + $context, + $model, + $property, + sprintf('Unsupported type "%s"', gettype($result)) + ), ViewModelRendererException::UnsupportedTypeForProperty ); } @@ -324,30 +365,37 @@ private function conditionalApply(DOMElement $context, object|iterable $model): foreach ($model as $current) { if (!is_object($current)) { throw new ViewModelRendererException( - 'Model must be an object when used for type of checks', + sprintf('Model must be an object when used for type of checks (%s)', $this->getModelPath($context)), ViewModelRendererException::WrongTypeForTypeOf ); } if (!method_exists($current, 'typeOf')) { throw new ViewModelRendererException( - 'Model must provide method typeOf for type of checks', + sprintf('Model must provide method typeOf for type of checks (%s)', $this->getModelPath($context)), ViewModelRendererException::TypeOfMethodRequired ); } + $typeOf = $current->typeOf(); + $matches = $this->xp->query( sprintf( 'following-sibling::*[@property="%s" and @typeof="%s"]', $context->getAttribute('property'), - $current->typeOf() + $typeOf ), $myPointer ); if ($matches->count() === 0) { throw new ViewModelRendererException( - 'No matching types found', + $this->buildExceptionMessage( + $context, + $current, + 'typeOf', + sprintf('No matching types for "%s" found', $typeOf) + ), ViewModelRendererException::NoMatch ); } @@ -390,8 +438,13 @@ private function iterableApply(DOMElement $context, iterable $list): void { if ($context->isSameNode($ownerDocument->documentElement)) { throw new ViewModelRendererException( - 'Cannot apply multiple on root element', - ViewModelRendererException::MultipleRootElements + $this->buildExceptionMessage( + $context, + $this->rootModel, + '???', + 'Cannot apply multiple models to root element' + ), + ViewModelRendererException::IterableForRootElement ); } @@ -400,7 +453,7 @@ private function iterableApply(DOMElement $context, iterable $list): void { $myPointer = $parent->insertBefore($this->pointer->cloneNode(), $context); - foreach ($list as $model) { + foreach ($list as $pos => $model) { $clone = $context->cloneNode(true); $parent->insertBefore($clone, $myPointer); @@ -430,7 +483,11 @@ private function iterableApply(DOMElement $context, iterable $list): void { } throw new ViewModelRendererException( - 'Unsupported type of model in list', + sprintf( + 'Unsupported type "%s" in list (%s)', + gettype($model), + $this->getModelPath($context) . '[' . $pos . ']' + ), ViewModelRendererException::UnsupportedTypeForProperty ); } @@ -462,7 +519,12 @@ private function objectApply(DOMElement $context, object $model): void { if (!is_string($textContent)) { throw new ViewModelRendererException( - 'Cannot use non string type for text content', + $this->buildExceptionMessage( + $context, + $model, + 'asString', + sprintf('Cannot use non string type (%s) for text content', gettype($textContent)) + ), ViewModelRendererException::StringRequired ); } @@ -515,7 +577,12 @@ private function objectApply(DOMElement $context, object $model): void { } throw new ViewModelRendererException( - \sprintf('Unsupported type "%s" for attribute', gettype($result)), + $this->buildExceptionMessage( + $context, + $model, + $name, + \sprintf('Unsupported type "%s" for attribute', gettype($result)), + ), ViewModelRendererException::WrongTypeForAttribute ); } @@ -540,4 +607,50 @@ private function documentApply(DOMElement $context, Document $model): void { ); } } + + private function getModelPath(DOMElement $context): string { + $list = [$context->getAttribute('property')]; + + while ($context = $context->parentNode) { + if ($context instanceof DOMDocument) { + break; + } + assert($context instanceof DOMElement); + + if (!$context->hasAttribute('property')) { + continue; + } + + $property = $context->getAttribute('property'); + $pos = 0; + $sibling = $context; + + while ($sibling = $sibling->previousSibling) { + assert($sibling instanceof DOMElement); + + if ($sibling->getAttribute('property') === $property) { + $pos++; + } + } + + if ($pos > 0) { + $property .= '[' . $pos . ']'; + } + + $list[] = $property; + } + + $list[] = 'root'; + + return implode(' > ', array_reverse($list)); + } + + private function buildExceptionMessage(?DOMElement $context, object $model, string $method, string $message): string { + return \sprintf( + '%s: %s (%s)', + $model::class . '::' . $method, + $message, + $context !== null ? $this->getModelPath($context) : 'root > ' . $method + ); + } } diff --git a/src/viewmodel/ViewModelRendererException.php b/src/viewmodel/ViewModelRendererException.php index 5f40b20..781c052 100644 --- a/src/viewmodel/ViewModelRendererException.php +++ b/src/viewmodel/ViewModelRendererException.php @@ -24,7 +24,7 @@ final class ViewModelRendererException extends Exception { public const UnsupportedTypeForProperty = 12; public const TypeOfMethodRequired = 13; public const NoMatch = 14; - public const MultipleRootElements = 15; + public const IterableForRootElement = 15; public const StringRequired = 16; public const WrongTypeForAttribute = 17; } diff --git a/tests/viewmodel/ViewModelRendererTest.php b/tests/viewmodel/ViewModelRendererTest.php index f92a8c9..7cf581a 100644 --- a/tests/viewmodel/ViewModelRendererTest.php +++ b/tests/viewmodel/ViewModelRendererTest.php @@ -47,6 +47,7 @@ public function testViewModelGetsAppliedAsExcepted(): void { public function testUsingContextElementWithOwnerDocumentThrowsException(): void { $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::NotInDocument); (new ViewModelRenderer())->render( new DOMElement('foo'), new class {} @@ -176,6 +177,8 @@ public function testNoMethodForPropertyThrowsException(): void { $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::ResolvingPropertyFailed); + $this->expectExceptionMessageMatches('/.*::test: No accessor for property \(root > test\)/'); $renderer->render($dom->documentElement, new class { }); } @@ -193,6 +196,8 @@ public function test() { $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::UnsupportedTypeForProperty); + $this->expectExceptionMessageMatches('/.*::test: Unsupported type "integer" \(root > test\)/'); $renderer->render($dom->documentElement, $model); } @@ -223,6 +228,8 @@ public function testMissingTypeOfMethodOnConditionContextThrowsException(): void $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::TypeOfMethodRequired); + $this->expectExceptionMessage('Model must provide method typeOf for type of checks (root > test)'); $renderer->render($dom->documentElement, new class { public function getTest() { return new class { @@ -255,6 +262,7 @@ public function testNoExsitingTypeForRequestedTypeOfMethodOnConditionContextThro $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::NoMatch); $renderer->render($dom->documentElement, new class { public function getTest() { return new class { @@ -273,6 +281,7 @@ public function testMultipleElementsForPropertyOnRootNodeThrowsException(): void $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::IterableForRootElement); $renderer->render($dom->documentElement, new class { public function getTest() { return ['a', 'b']; @@ -287,6 +296,7 @@ public function testEmptyArrayForPropertyOnRootNodeThrowsException(): void { $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::IterableForRootElement); $renderer->render($dom->documentElement, new class { public function getTest() { return []; @@ -486,6 +496,8 @@ public function testUsingAResourceWithNoMethodToRequestItThrowsException(): void $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::ResourceResolvingFailed); + $this->expectExceptionMessage('stdClass::foo: Cannot resolve resource request (root > foo)'); $renderer->render($dom->documentElement, new \stdClass()); } @@ -496,6 +508,7 @@ public function testResolvingResourceToNonObjectThrowsException(): void { $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::WrongTypeForResource); $renderer->render($dom->documentElement, new class { public function foo(): string { return 'crash'; }}); } @@ -548,6 +561,7 @@ public function testUsingAPrefixWithNoMethodToRequestItThrowsException(): void { $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::PrefixResolvingFailed); $renderer->render($dom->documentElement, new \stdClass()); } @@ -558,6 +572,7 @@ public function testResolvingPrefixToNonObjectThrowsException(): void { $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::WrongTypeForPrefix); $renderer->render($dom->documentElement, new class { public function foo(): string { return 'crash'; }}); } @@ -568,6 +583,7 @@ public function testUsingAnUndefinedPrefixThrowsException(): void { $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::NoModelForPrefix); $renderer->render($dom->documentElement, new \stdClass()); } @@ -578,6 +594,8 @@ public function testInvalidPrefixDefinitionThrowsException(): void { $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::InvalidPrefixDefinition); + $renderer->render($dom->documentElement, new \stdClass()); } @@ -611,6 +629,8 @@ public function test(): bool { $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::ResolvingPropertyFailed); + $this->expectExceptionMessageMatches('/.*.::foo: No accessor for property \(root > test > foo\)/'); $renderer->render($dom->documentElement, $class); } @@ -673,6 +693,7 @@ public function asString() { $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::StringRequired); $renderer->render($dom->documentElement, $class); } @@ -689,6 +710,7 @@ public function test(): array { $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::WrongTypeForTypeOf); $renderer->render($dom->documentElement, $class); } @@ -709,6 +731,7 @@ public function typeOf() { $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::NoMatch); $renderer->render($dom->documentElement, $class); } @@ -773,6 +796,7 @@ public function testNonStringResponseForVocabThrowsException(): void { $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::WrongTypeForVocab); $renderer->render( $dom->documentElement, new class { @@ -792,6 +816,7 @@ public function testAttemptToUseNonObjectOrIterableModelForTypeOfThrowsException $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::WrongTypeForTypeOf); $renderer->render( $dom->documentElement, new class { @@ -811,6 +836,8 @@ public function testUnsupportedModelTypeAsListItemThrowsException(): void { $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::UnsupportedTypeForProperty); + $this->expectExceptionMessage('Unsupported type "boolean" in list (root > foo[0])'); $renderer->render( $dom->documentElement, new class { @@ -882,6 +909,7 @@ public function testResourceResovlingUsingUndefinedPrefixThrowsException(): void $renderer = new ViewModelRenderer(); $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::ResourceResolvingWithPrefixFailed); $renderer->render( $dom->documentElement, new class {} @@ -1112,4 +1140,24 @@ public function document(): object { ] ]; } + + public function testErrorOnSecondSiblingShowsInTraceLine(): void { + $document = Document::fromString('

'); + + $this->expectException(ViewModelRendererException::class); + $this->expectExceptionCode(ViewModelRendererException::UnsupportedTypeForProperty); + $this->expectExceptionMessageMatches('/.*Unsupported type "resource" \(root > p\[2\] > s\)/'); + $document->applyViewModel(new class { + public function p(): array { + return [ + 'a','b', + new class { + public function s() { + return STDIN; + } + } + ]; + } + }); + } }