diff --git a/.github/workflows/throws.yaml b/.github/workflows/throws.yaml index 4609137..694057a 100644 --- a/.github/workflows/throws.yaml +++ b/.github/workflows/throws.yaml @@ -24,7 +24,7 @@ jobs: run: composer update --prefer-dist --no-progress --no-suggest - name: Run the throw counting tests run: composer test:throws - - name: Report intermediate throws over the whole suite + - name: Report and fail on intermediate throws over the whole suite run: | status=0 composer throws:report > throws-report.txt 2>&1 || status=$? diff --git a/composer.json b/composer.json index bd34deb..9d9d21a 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "php-standard-library/vec": "^6.1", "veewee/reflecta": "^1.1", "veewee/xml": "^4.10", - "php-soap/engine": "^2.20", + "php-soap/engine": "^2.21", "php-soap/wsdl": "^1.19", "php-soap/xml": "^1.10", "php-soap/wsdl-reader": "~0.32" diff --git a/src/Encoder/SoapEnc/SoapArrayAccess.php b/src/Encoder/SoapEnc/SoapArrayAccess.php index 437dfc5..69409f8 100644 --- a/src/Encoder/SoapEnc/SoapArrayAccess.php +++ b/src/Encoder/SoapEnc/SoapArrayAccess.php @@ -11,7 +11,6 @@ use Soap\WsdlReader\Model\Definitions\BindingUse; use Soap\WsdlReader\Parser\Xml\QnameParser; use Soap\Xml\Xmlns; -use function Psl\Result\try_catch; final class SoapArrayAccess { @@ -38,16 +37,14 @@ public static function forContext(Context $context): self $itemNodeName = $meta->arrayNodeName()->unwrapOr(null); $xsiType = ltrim($nodePrefix . ':' . $nodeType, ':'); - $xsdType = try_catch( - static fn (): XsdType => $context->metadata->getTypes() - ->fetchByNameAndXmlNamespace($nodeType, $namespace) - ->getXsdType(), - static fn (): XsdType => XsdType::any() + $xsdType = $context->metadata->getTypes() + ->findByNameAndXmlNamespace($nodeType, $namespace) + ?->getXsdType() + ?? XsdType::any() ->copy($nodeType) ->withXmlTypeName($nodeType) ->withXmlNamespace($namespace) - ->withMeta(static fn (TypeMeta $meta): TypeMeta => $meta->withIsElement(true)) - ); + ->withMeta(static fn (TypeMeta $meta): TypeMeta => $meta->withIsElement(true)); if ($context->bindingUse === BindingUse::ENCODED || $itemNodeName !== null) { $xsdType = $xsdType->withXmlTargetNodeName($itemNodeName ?? 'item'); diff --git a/tests/Integration/IntermediateThrowScenariosTest.php b/tests/Integration/IntermediateThrowScenariosTest.php index 325ee1f..49a04cb 100644 --- a/tests/Integration/IntermediateThrowScenariosTest.php +++ b/tests/Integration/IntermediateThrowScenariosTest.php @@ -19,7 +19,7 @@ /** * Encodes and decodes that succeed while exceptions are thrown and caught internally along the way: * stdClass hydration, absent elements and attributes, absent and uninitialised properties on encode, - * untyped scalar values and soap-enc arrays of a builtin item type. + * untyped scalar values and the soap-enc arrays, structs and apache maps of SOAP 1.1 and SOAP 1.2 encoding. */ #[CoversClass(Driver::class)] final class IntermediateThrowScenariosTest extends TestCase @@ -45,6 +45,27 @@ final class IntermediateThrowScenariosTest extends TestCase + + + + + + + + + + + + + + + + + + + + + @@ -179,18 +200,211 @@ public static function provideScenarios(): iterable ), 'expected' => [1, 2], ]; + + $addressArray = self::driver('tns:addressArray', 'encoded'); + yield 'decode soap-enc array of a complex type' => [ + 'operation' => static fn (): mixed => $addressArray->decode( + 'test', + self::response( + '' + .'SC' + .'' + ), + ), + 'expected' => [ + (object) ['street' => 'S', 'city' => 'C', 'zip' => null], + (object) ['street' => null, 'city' => null, 'zip' => null], + ], + ]; + + yield 'encode soap-enc array of a complex type' => [ + 'operation' => static fn (): string => $addressArray->encode('test', [[(object) ['city' => 'C', 'zip' => '1000']]])->getRequest(), + 'expected' => self::encodedRequest( + '' + .'' + .'C' + .'1000' + .'' + ), + ]; + + $arrayOfArrays = self::driver('tns:intArrayArray', 'encoded'); + yield 'decode soap-enc array of arrays' => [ + 'operation' => static fn (): mixed => $arrayOfArrays->decode( + 'test', + self::response( + '' + .'12' + .'' + .'' + ), + ), + 'expected' => [[1, 2], []], + ]; + + yield 'encode soap-enc array of arrays' => [ + 'operation' => static fn (): string => $arrayOfArrays->encode('test', [[[1], []]])->getRequest(), + 'expected' => self::encodedRequest( + '' + .'' + .'1' + .'' + .'' + .'' + ), + ]; + + yield 'decode soap-enc array with nil items' => [ + 'operation' => static fn (): mixed => $soapEncArray->decode( + 'test', + self::response('1'), + ), + 'expected' => [1, null], + ]; + + yield 'encode empty soap-enc array' => [ + 'operation' => static fn (): string => $soapEncArray->encode('test', [[]])->getRequest(), + 'expected' => self::encodedRequest( + '' + ), + ]; + + $anyTypeArray = self::driver('tns:anyTypeArray', 'encoded'); + yield 'decode soap-enc array with an xsi:type per item' => [ + 'operation' => static fn (): mixed => $anyTypeArray->decode( + 'test', + self::response( + '' + .'1a' + .'1.5true' + .'' + ), + ), + 'expected' => [1, 'a', 1.5, true], + ]; + + yield 'encode soap-enc array with an xsi:type per item' => [ + 'operation' => static fn (): string => $anyTypeArray->encode('test', [[1, 'a']])->getRequest(), + 'expected' => self::encodedRequest( + '' + .'1' + .'a' + .'' + ), + ]; + + $apacheMap = self::driver('apache:Map', 'encoded'); + yield 'decode apache map' => [ + 'operation' => static fn (): mixed => $apacheMap->decode( + 'test', + self::response( + '' + .'a1' + .'bx' + .'' + ), + ), + 'expected' => ['a' => 1, 'b' => 'x'], + ]; + + yield 'encode apache map' => [ + 'operation' => static fn (): string => $apacheMap->encode('test', [['a' => 1]])->getRequest(), + 'expected' => self::encodedRequest( + '' + .'a' + .'1' + .'' + ), + ]; + + $struct = self::driver('SOAP-ENC:Struct', 'encoded'); + yield 'decode soap-enc struct' => [ + 'operation' => static fn (): mixed => $struct->decode( + 'test', + self::response('1x'), + ), + 'expected' => (object) ['a' => 1, 'b' => 'x'], + ]; + + yield 'encode soap-enc struct' => [ + 'operation' => static fn (): string => $struct->encode('test', [(object) ['a' => 1]])->getRequest(), + 'expected' => self::encodedRequest( + '' + .'1' + .'' + ), + ]; + + $soap12Array = self::driver('tns:soap12IntArray', 'encoded', soap12: true); + yield 'decode soap 1.2 encoded array' => [ + 'operation' => static fn (): mixed => $soap12Array->decode( + 'test', + self::response('12', soap12: true), + ), + 'expected' => [1, 2], + ]; + + yield 'encode soap 1.2 encoded array' => [ + 'operation' => static fn (): string => $soap12Array->encode('test', [[1]])->getRequest(), + 'expected' => self::encodedRequest( + '' + .'1' + .'', + soap12: true, + ), + ]; + + $soap12AddressArray = self::driver('tns:soap12AddressArray', 'encoded', soap12: true); + yield 'decode soap 1.2 encoded array of a complex type' => [ + 'operation' => static fn (): mixed => $soap12AddressArray->decode( + 'test', + self::response( + 'C', + soap12: true, + ), + ), + 'expected' => [(object) ['street' => null, 'city' => 'C', 'zip' => null]], + ]; + + $soap12Literal = self::driver('tns:itemType', 'literal', soap12: true); + yield 'decode soap 1.2 literal envelope' => [ + 'operation' => static fn (): mixed => $soap12Literal->decode( + 'test', + self::response('42', soap12: true), + ), + 'expected' => (object) [ + 'code' => null, + 'rank' => null, + 'id' => 42, + 'name' => null, + 'price' => null, + 'active' => null, + 'address' => null, + 'tags' => [], + ], + ]; + + yield 'encode soap 1.2 literal envelope' => [ + 'operation' => static fn (): string => $soap12Literal->encode('test', [(object) ['id' => 1]])->getRequest(), + 'expected' => self::literalRequest( + '1
', + soap12: true, + ), + ]; } - private static function driver(string $partType, string $use, ?EncoderRegistry $registry = null): Driver + private static function driver(string $partType, string $use, ?EncoderRegistry $registry = null, bool $soap12 = false): Driver { $schema = self::ITEM_SCHEMA; - $encodingStyle = $use === 'encoded' ? ' encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"' : ''; + $encodingStyle = $use === 'encoded' ? ' encodingStyle="'.self::encodingNamespace($soap12).'"' : ''; + $bindingNamespace = $soap12 ? 'http://schemas.xmlsoap.org/wsdl/soap12/' : 'http://schemas.xmlsoap.org/wsdl/soap/'; $wsdl = << @@ -228,22 +442,46 @@ private static function driver(string $partType, string $use, ?EncoderRegistry $ ); } - private static function response(string $testParam): SoapResponse + private static function response(string $testParam, bool $soap12 = false): SoapResponse { return new SoapResponse( - '' + .' xmlns:SOAP-ENC="'.self::encodingNamespace($soap12).'"' + .' xmlns:apache="http://xml.apache.org/xml-soap" xmlns:tns="http://test-uri/">' .''.$testParam.'' .'' ); } - private static function literalRequest(string $testParam): string + private static function literalRequest(string $testParam, bool $soap12 = false): string + { + $envelope = self::envelopeNamespace($soap12); + + return '' + .'' + .''.$testParam.'' + .''."\n"; + } + + private static function encodedRequest(string $testParam, bool $soap12 = false): string { - return '' - .'' + $envelope = self::envelopeNamespace($soap12); + $encoding = self::encodingNamespace($soap12); + + return '' + .'' .''.$testParam.'' .''."\n"; } + + private static function envelopeNamespace(bool $soap12): string + { + return $soap12 ? 'http://www.w3.org/2003/05/soap-envelope' : 'http://schemas.xmlsoap.org/soap/envelope/'; + } + + private static function encodingNamespace(bool $soap12): string + { + return $soap12 ? 'http://www.w3.org/2003/05/soap-encoding' : 'http://schemas.xmlsoap.org/soap/encoding/'; + } } diff --git a/tests/Integration/NoIntermediateThrowsTest.php b/tests/Integration/NoIntermediateThrowsTest.php index b0a3842..cfadffe 100644 --- a/tests/Integration/NoIntermediateThrowsTest.php +++ b/tests/Integration/NoIntermediateThrowsTest.php @@ -16,7 +16,6 @@ use Soap\Encoding\Test\Unit\ContextCreatorTrait; use Soap\Engine\Metadata\Model\TypeMeta; use Soap\Engine\Metadata\Model\XsdType; -use function in_array; use function var_export; #[CoversNothing] @@ -26,13 +25,6 @@ final class NoIntermediateThrowsTest extends TestCase use AssertsNoThrows; use ContextCreatorTrait; - /** - * The engine type lookup for soap-enc arrays of a builtin item type still throws. - */ - private const STILL_THROWING = [ - 'decode soap-enc array of a builtin type', - ]; - /** * @param Closure(): mixed $operation */ @@ -97,10 +89,6 @@ public static function provideUntypedDecodes(): iterable */ public static function provideScenarios(): iterable { - foreach (IntermediateThrowScenariosTest::provideScenarios() as $name => $scenario) { - if (!in_array($name, self::STILL_THROWING, true)) { - yield $name => $scenario; - } - } + return IntermediateThrowScenariosTest::provideScenarios(); } } diff --git a/tests/Support/Throws/AllowedThrows.php b/tests/Support/Throws/AllowedThrows.php new file mode 100644 index 0000000..0c6163d --- /dev/null +++ b/tests/Support/Throws/AllowedThrows.php @@ -0,0 +1,95 @@ +, via: string, throws: int, reason: string} + */ +final class AllowedThrows +{ + /** @var array */ + private array $entries = []; + /** @var array */ + private array $seen = []; + + /** + * @param list $entries + */ + public function __construct(array $entries) + { + foreach ($entries as $entry) { + $this->entries[self::key($entry['test'], $entry['throw'], $entry['via'])] = $entry; + } + } + + public static function fromFile(): self + { + /** @var list $entries */ + $entries = require __DIR__.'/allowed-throws.php'; + + return new self($entries); + } + + public function allows(string $test, ThrowEvent $event): bool + { + $key = self::key($test, $event->class, $event->srcCall() ?? ''); + if (!isset($this->entries[$key])) { + return false; + } + + $this->seen[$key] = ($this->seen[$key] ?? 0) + 1; + + return $this->seen[$key] <= $this->entries[$key]['throws']; + } + + public function allowed(): int + { + $allowed = 0; + foreach ($this->seen as $key => $seen) { + $allowed += min($seen, $this->entries[$key]['throws']); + } + + return $allowed; + } + + /** + * Entries whose test ran but threw less often than listed, so the list can shrink. + * + * @param array $ranTests + * + * @return list + */ + public function unused(array $ranTests): array + { + $unused = []; + foreach ($this->entries as $key => $entry) { + $seen = $this->seen[$key] ?? 0; + if (isset($ranTests[$entry['test']]) && $seen < $entry['throws']) { + $unused[] = sprintf( + '%s: %s via %s threw %d of %d times', + $entry['test'], + $entry['throw'], + $entry['via'], + $seen, + $entry['throws'], + ); + } + } + + return $unused; + } + + private static function key(string $test, string $throw, string $via): string + { + return $test."\0".$throw."\0".$via; + } +} diff --git a/tests/Support/Throws/ThrowAttribution.php b/tests/Support/Throws/ThrowAttribution.php index 6921047..9efee1f 100644 --- a/tests/Support/Throws/ThrowAttribution.php +++ b/tests/Support/Throws/ThrowAttribution.php @@ -6,6 +6,7 @@ use PHPUnit\Event\Code\Test; use PHPUnit\Event\Code\TestMethod; use ReflectionMethod; +use function array_keys; use function array_slice; use function count; use function file; @@ -16,16 +17,20 @@ /** * Attributes the throws of a suite run to their sites, keeping only the intermediate ones: - * thrown while the stack passes through src/, in a test that does not expect an exception. + * thrown while the stack passes through src/, in a test that does not expect an exception + * and not listed as deliberate in allowed-throws.php. */ final class ThrowAttribution { + private const SHOWN_TESTS_PER_SITE = 3; + /** @var array|null */ private ?array $previousIni = null; private string $currentTest = ''; private ?string $currentExclusion = null; private int $all = 0; private int $outsideSrc = 0; + private int $intermediate = 0; /** @var array{expectException: int, catch: int} */ private array $excluded = ['expectException' => 0, 'catch' => 0]; /** @var array> */ @@ -36,10 +41,18 @@ final class ThrowAttribution private array $tests = []; /** @var array */ private array $unsuccessfulTests = []; + /** @var array */ + private array $ranTests = []; + + public function __construct( + private readonly AllowedThrows $allowedThrows, + ) { + } public function testStarted(Test $test): void { $this->currentTest = $test->id(); + $this->ranTests[$this->currentTest] = true; $this->currentExclusion = $test instanceof TestMethod ? $this->exclusion($test) : null; $this->previousIni ??= ThrowCounter::enable(); } @@ -56,7 +69,11 @@ public function testPrinted(string $output): void $this->outsideSrc++; continue; } + if ($this->allowedThrows->allows($this->currentTest, $event)) { + continue; + } + $this->intermediate++; $site = $event->describe(); $this->sites[$site] ??= ['throws' => 0, 'tests' => []]; $this->sites[$site]['throws']++; @@ -83,32 +100,50 @@ public function stopTracing(): void } } + /** + * True when the run has intermediate throws, allowlist entries that no longer throw as listed, + * or failed tests, whose throws cannot be trusted. + */ + public function failed(): bool + { + return $this->intermediate > 0 + || $this->allowedThrows->unused($this->ranTests) !== [] + || $this->unsuccessfulTests !== []; + } + public function render(): string { $sites = $this->sites; uasort($sites, static fn (array $a, array $b): int => $b['throws'] <=> $a['throws']); $lines = [ + sprintf('Intermediate throws: %d in %d tests', $this->intermediate, count($this->tests)), sprintf( - 'Intermediate throws: %d in %d tests', - $this->all - $this->outsideSrc - $this->excluded['expectException'] - $this->excluded['catch'], - count($this->tests), - ), - sprintf( - 'Not counted: %d outside src/, %d in tests using expectException(), %d in tests catching an exception themselves (%d throws in total)', + 'Not counted: %d outside src/, %d in tests using expectException(), %d in tests catching an exception themselves, %d allowed in tests/Support/Throws/allowed-throws.php (%d throws in total)', $this->outsideSrc, $this->excluded['expectException'], $this->excluded['catch'], + $this->allowedThrows->allowed(), $this->all, ), ]; if ($this->unsuccessfulTests !== []) { $lines[] = sprintf('Warning: %d tests failed or errored, run composer test to see them', count($this->unsuccessfulTests)); } + foreach ($this->allowedThrows->unused($this->ranTests) as $unused) { + $lines[] = 'Allowed throw no longer thrown as listed, update allowed-throws.php: '.$unused; + } $lines[] = ''; $lines[] = sprintf('%7s %6s %s', 'throws', 'tests', 'site'); foreach ($sites as $site => $count) { $lines[] = sprintf('%7d %6d %s', $count['throws'], count($count['tests']), $site); + $tests = array_keys($count['tests']); + foreach (array_slice($tests, 0, self::SHOWN_TESTS_PER_SITE) as $test) { + $lines[] = sprintf('%15s %s', '', $test); + } + if (count($tests) > self::SHOWN_TESTS_PER_SITE) { + $lines[] = sprintf('%15s and %d more', '', count($tests) - self::SHOWN_TESTS_PER_SITE); + } } return implode("\n", $lines)."\n"; diff --git a/tests/Support/Throws/ThrowAttributionExtension.php b/tests/Support/Throws/ThrowAttributionExtension.php index cd63ecd..7060533 100644 --- a/tests/Support/Throws/ThrowAttributionExtension.php +++ b/tests/Support/Throws/ThrowAttributionExtension.php @@ -3,6 +3,8 @@ namespace Soap\Encoding\Test\Support\Throws; +use PHPUnit\Event\Application\Finished as ApplicationFinished; +use PHPUnit\Event\Application\FinishedSubscriber as ApplicationFinishedSubscriber; use PHPUnit\Event\Test\Errored; use PHPUnit\Event\Test\ErroredSubscriber; use PHPUnit\Event\Test\Failed; @@ -24,7 +26,7 @@ use RuntimeException; /** - * Prints the intermediate throws of a suite run per site, see composer throws:report. + * Prints the intermediate throws of a suite run per site and fails the run when there are any, see composer throws:report. * * Xdebug prints each exception trace into the output buffer of the running test, so every test that throws * counts as printing output. The composer script runs with --no-results and --do-not-fail-on-risky for that @@ -38,7 +40,7 @@ public function bootstrap(Configuration $configuration, Facade $facade, Paramete throw new RuntimeException('The throws report needs Xdebug with xdebug.mode=develop, run composer throws:report.'); } - $attribution = new ThrowAttribution(); + $attribution = new ThrowAttribution(AllowedThrows::fromFile()); $facade->registerSubscribers( new class($attribution) implements PreparationStartedSubscriber { @@ -112,6 +114,19 @@ public function notify(ExecutionFinished $event): void print "\n".$this->attribution->render(); } }, + new class($attribution) implements ApplicationFinishedSubscriber { + public function __construct(private readonly ThrowAttribution $attribution) + { + } + + public function notify(ApplicationFinished $event): void + { + // An extension cannot change PHPUnit's exit code, and this is the last event before PHPUnit exits. + if ($event->shellExitCode() === 0 && $this->attribution->failed()) { + exit(1); + } + } + }, ); } } diff --git a/tests/Support/Throws/ThrowEvent.php b/tests/Support/Throws/ThrowEvent.php index 5ed6a8a..5358d1b 100644 --- a/tests/Support/Throws/ThrowEvent.php +++ b/tests/Support/Throws/ThrowEvent.php @@ -40,9 +40,31 @@ public function srcCaller(): ?string return $this->site(); } + $frame = $this->srcFrame(); + + return $frame !== null ? self::relative($frame->file).':'.$frame->line.' -> '.$frame->function : null; + } + + /** + * The innermost file in src/ that led to this throw and the function it called there, without line numbers, + * so it stays the same when unrelated lines in that file move. + */ + public function srcCall(): ?string + { + if (self::isSrc($this->file)) { + return self::relative($this->file); + } + + $frame = $this->srcFrame(); + + return $frame !== null ? self::relative($frame->file).' -> '.$frame->function : null; + } + + private function srcFrame(): ?ThrowFrame + { foreach (array_reverse($this->frames) as $frame) { if (self::isSrc($frame->file)) { - return self::relative($frame->file).':'.$frame->line.' -> '.$frame->function; + return $frame; } } diff --git a/tests/Support/Throws/allowed-throws.php b/tests/Support/Throws/allowed-throws.php new file mode 100644 index 0000000..d42c8fc --- /dev/null +++ b/tests/Support/Throws/allowed-throws.php @@ -0,0 +1,26 @@ + ObjectEncoderCustomLensTest::class.'::test_it_decodes_through_custom_lenses_that_throw_on_a_missing_element', + 'throw' => ArrayAccessException::class, + 'via' => 'src/Encoder/ObjectEncoder.php -> VeeWee\\Reflecta\\Lens\\Lens->get', + 'throws' => 1, + 'reason' => 'The fixture is a third-party encoder whose decoder lens throws on a missing element, to prove custom lenses still get the optional() and runLens treatment.', + ], + [ + 'test' => ObjectEncoderCustomLensTest::class.'::test_it_encodes_through_custom_lenses_that_throw_on_a_missing_property', + 'throw' => UnreflectableException::class, + 'via' => 'src/Encoder/ObjectEncoder.php -> VeeWee\\Reflecta\\Lens\\Lens->get', + 'throws' => 1, + 'reason' => 'The fixture is a third-party encoder whose encoder lens throws on a missing property, to prove custom lenses still get the optional() and runLens treatment.', + ], +]; diff --git a/tests/Unit/Encoder/Feature/ElementAwareEncoderTest.php b/tests/Unit/Encoder/Feature/ElementAwareEncoderTest.php index 9d72a02..d1c4202 100644 --- a/tests/Unit/Encoder/Feature/ElementAwareEncoderTest.php +++ b/tests/Unit/Encoder/Feature/ElementAwareEncoderTest.php @@ -2,6 +2,7 @@ namespace Soap\Encoding\Test\Unit\Encoder\Feature; +use Closure; use PHPUnit\Framework\Attributes\CoversClass; use Soap\Encoding\Encoder\Context; use Soap\Encoding\Encoder\Feature\ElementAware; @@ -12,8 +13,13 @@ use Soap\Engine\Metadata\Model\XsdType; use Soap\Xml\Xmlns; use VeeWee\Reflecta\Iso\Iso; -use function VeeWee\Xml\Encoding\document_encode; +use VeeWee\Xml\Writer\Writer; +use function Psl\Dict\map_with_key; use function VeeWee\Xml\Encoding\xml_decode; +use function VeeWee\Xml\Writer\Builder\children; +use function VeeWee\Xml\Writer\Builder\element; +use function VeeWee\Xml\Writer\Builder\value; +use function VeeWee\Xml\Writer\Mapper\memory_output; #[CoversClass(EncoderDetector::class)] final class ElementAwareEncoderTest extends AbstractEncoderTests @@ -31,7 +37,12 @@ public function iso(Context $context): Iso { $typeName = $context->type->getXmlTargetNodeName(); return new Iso( - to: static fn (array $data): string => document_encode([$typeName => $data])->stringifyDocumentElement(), + to: static fn (array $data): string => Writer::inMemory() + ->write(element($typeName, children(map_with_key( + $data, + static fn (string $key, string $value): Closure => element($key, value($value)), + )))) + ->map(memory_output()), from: static fn (string $xml): array => xml_decode($xml)[$typeName], ); } diff --git a/tests/Unit/Support/Throws/AllowedThrowsTest.php b/tests/Unit/Support/Throws/AllowedThrowsTest.php new file mode 100644 index 0000000..ef4469a --- /dev/null +++ b/tests/Unit/Support/Throws/AllowedThrowsTest.php @@ -0,0 +1,78 @@ +allows(self::TEST, self::event(RuntimeException::class))); + static::assertTrue($allowed->allows(self::TEST, self::event(RuntimeException::class))); + static::assertFalse($allowed->allows(self::TEST, self::event(RuntimeException::class))); + static::assertSame(2, $allowed->allowed()); + } + + public function test_it_only_allows_the_listed_test_class_and_site(): void + { + $allowed = self::allowed(throws: 1); + + static::assertFalse($allowed->allows('Some\\Test::test_something_else', self::event(RuntimeException::class))); + static::assertFalse($allowed->allows(self::TEST, self::event(LogicException::class))); + static::assertFalse($allowed->allows(self::TEST, self::event(RuntimeException::class, 'Other\\Lens->get'))); + static::assertSame(0, $allowed->allowed()); + } + + public function test_it_reports_entries_of_tests_that_ran_without_throwing_as_listed(): void + { + $allowed = self::allowed(throws: 2); + $allowed->allows(self::TEST, self::event(RuntimeException::class)); + + static::assertSame([], $allowed->unused([])); + static::assertSame( + [self::TEST.': RuntimeException via src/Encoder/ObjectEncoder.php -> Some\\Lens->get threw 1 of 2 times'], + $allowed->unused([self::TEST => true]), + ); + } + + private static function allowed(int $throws): AllowedThrows + { + return new AllowedThrows([ + [ + 'test' => self::TEST, + 'throw' => RuntimeException::class, + 'via' => 'src/Encoder/ObjectEncoder.php -> Some\\Lens->get', + 'throws' => $throws, + 'reason' => 'The fixture throws on purpose.', + ], + ]); + } + + /** + * @param class-string $class + */ + private static function event(string $class, string $srcFunction = 'Some\\Lens->get'): ThrowEvent + { + $root = dirname(__DIR__, 4); + + return new ThrowEvent($class, 'message', $root.'/vendor/some/Thrower.php', 12, [ + new ThrowFrame('{main}', $root.'/vendor/bin/phpunit', 0), + new ThrowFrame($srcFunction, $root.'/src/Encoder/ObjectEncoder.php', 160), + ]); + } +}