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(
+ ''
+ .'- 1
- 2
'
+ .' '
+ .''
+ ),
+ ),
+ '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(
+ ''
+ .'- 1
- a
'
+ .'- 1.5
- true
'
+ .''
+ ),
+ ),
+ '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('- 1
- 2
', 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),
+ ]);
+ }
+}