diff --git a/src/Drivers/AbstractQuantumDriver.php b/src/Drivers/AbstractQuantumDriver.php index af06efa..10b8fc3 100644 --- a/src/Drivers/AbstractQuantumDriver.php +++ b/src/Drivers/AbstractQuantumDriver.php @@ -156,6 +156,39 @@ private function assertWithinQubitCeiling(CircuitBuilder $circuit): void } } + /** + * Return $response[$key] once it is present and $isValid accepts it, or + * throw the malformed-response exception every script shares. + * + * The single funnel for "key present and of the right shape" checks on + * decoded Python output, so each call site states only what differs: the + * script, the key, the predicate and how to describe the expected value. + * $subject names what the key belongs to when it is not the response + * itself (e.g. "each result" for the items of a batch). + * + * @param array $response + * @param \Closure(mixed): bool $isValid + * + * @throws QuantumExecutionException + */ + private function expectKey( + array $response, + string $script, + string $key, + \Closure $isValid, + string $expected, + string $subject = 'the response' + ): mixed { + if (! array_key_exists($key, $response) || ! $isValid($response[$key])) { + throw QuantumExecutionException::malformedResponse( + $script, + "expected {$subject} to have a \"{$key}\" key holding {$expected}." + ); + } + + return $response[$key]; + } + /** * Wrap script input in the envelope every bin/python script expects: the * data itself plus the driver name and config the provider layer reads. @@ -189,30 +222,29 @@ public function executeBatch(array $circuits): BatchResult $response = $this->bridge->execute('batch.py', $payload, $this->config); - if (! array_key_exists('results', $response) || ! is_array($response['results'])) { - throw QuantumExecutionException::malformedResponse( - 'batch.py', - 'expected the "results" key to be present and hold an array.' - ); - } + /** @var array $results */ + $results = $this->expectKey($response, 'batch.py', 'results', is_array(...), 'an array'); - if (count($response['results']) !== count($circuits)) { + if (count($results) !== count($circuits)) { throw QuantumExecutionException::malformedResponse( 'batch.py', - 'expected exactly '.count($circuits).' results, got '.count($response['results']).'.' + 'expected exactly '.count($circuits).' results, got '.count($results).'.' ); } $circuitResults = []; - foreach ($response['results'] as $result) { - if (! is_array($result) || ! array_key_exists('counts', $result) || ! is_array($result['counts'])) { + foreach (array_values($results) as $index => $result) { + if (! is_array($result)) { throw QuantumExecutionException::malformedResponse( 'batch.py', - 'expected each result to have a "counts" array.' + "expected result #{$index} to be an object, got ".get_debug_type($result).'.' ); } - $circuitResults[] = new CircuitResult($result['counts']); + /** @var array $counts */ + $counts = $this->expectKey($result, 'batch.py', 'counts', is_array(...), 'an array', "result #{$index}"); + + $circuitResults[] = new CircuitResult($counts); } // Announced only once the whole response has been validated, so a @@ -275,14 +307,10 @@ private function runDefinition(array $definition): CircuitResult { $response = $this->bridge->execute('circuit.py', $this->payload($definition), $this->config); - if (! array_key_exists('counts', $response) || ! is_array($response['counts'])) { - throw QuantumExecutionException::malformedResponse( - 'circuit.py', - 'expected the "counts" key to be present and hold an array.' - ); - } + /** @var array $counts */ + $counts = $this->expectKey($response, 'circuit.py', 'counts', is_array(...), 'an array'); - return new CircuitResult($response['counts']); + return new CircuitResult($counts); } /** @@ -305,14 +333,14 @@ protected function submitTask(CircuitBuilder $circuit): string $response = $this->bridge->execute('submit.py', $this->payload($circuit->toArray()), $this->config); - $taskArn = $response['task_arn'] ?? null; - - if (! is_string($taskArn) || trim($taskArn) === '') { - throw QuantumExecutionException::malformedResponse( - 'submit.py', - 'expected the "task_arn" key to be present and hold a non-empty string.' - ); - } + /** @var string $taskArn */ + $taskArn = $this->expectKey( + $response, + 'submit.py', + 'task_arn', + static fn (mixed $value): bool => is_string($value) && trim($value) !== '', + 'a non-empty string' + ); return $taskArn; } @@ -332,16 +360,16 @@ protected function pollTask(string $taskArn): TaskSnapshot $response = $this->bridge->execute('check.py', $this->payload(['task_arn' => $taskArn]), $this->config); - $status = $response['status'] ?? null; + /** @var string $status */ + $status = $this->expectKey( + $response, + 'check.py', + 'status', + static fn (mixed $value): bool => is_string($value) && TaskStatus::tryFrom($value) !== null, + 'a valid task status value' + ); - if (! is_string($status) || TaskStatus::tryFrom($status) === null) { - throw QuantumExecutionException::malformedResponse( - 'check.py', - 'expected the "status" key to be present and hold a valid task status value.' - ); - } - - return TaskSnapshot::fromResponse($response); + return TaskSnapshot::fromResponse($response, TaskStatus::from($status)); } public function generateEntropy(int $bits): string @@ -366,21 +394,17 @@ public function generateEntropy(int $bits): string $response = $this->bridge->execute('entropy.py', $payload, $this->config); - if (! array_key_exists('bits', $response) || ! is_string($response['bits'])) { - throw QuantumExecutionException::malformedResponse( - 'entropy.py', - 'expected the "bits" key to be present and hold a string.' - ); - } + /** @var string $bitstring */ + $bitstring = $this->expectKey($response, 'entropy.py', 'bits', is_string(...), 'a string'); - if (strlen($response['bits']) < $bits) { + if (strlen($bitstring) < $bits) { throw QuantumExecutionException::malformedResponse( 'entropy.py', - "expected at least {$bits} bits in the response, got ".strlen($response['bits']).'.' + "expected at least {$bits} bits in the response, got ".strlen($bitstring).'.' ); } - $bitstring = substr($response['bits'], 0, $bits); + $bitstring = substr($bitstring, 0, $bits); $this->dispatchEvent(new EntropyGenerated($this->driverName(), $bits)); diff --git a/src/Tasks/TaskSnapshot.php b/src/Tasks/TaskSnapshot.php index 422feb1..ef7322e 100644 --- a/src/Tasks/TaskSnapshot.php +++ b/src/Tasks/TaskSnapshot.php @@ -20,13 +20,23 @@ public function __construct( /** * Build a snapshot from a decoded check-script response. * + * Pass $status when the caller has already validated the response's + * `status` key (AbstractQuantumDriver::pollTask() does), so the value the + * guard checked is the one the snapshot carries; otherwise the key is + * parsed here. + * * @param array $response */ - public static function fromResponse(array $response): self + public static function fromResponse(array $response, ?TaskStatus $status = null): self { + $counts = $response['counts'] ?? null; + + /** @var array|null $counts */ + $counts = is_array($counts) ? $counts : null; + return new self( - TaskStatus::from((string) ($response['status'] ?? '')), - isset($response['counts']) && is_array($response['counts']) ? $response['counts'] : null, + $status ?? TaskStatus::from((string) ($response['status'] ?? '')), + $counts, ); } } diff --git a/tests/Unit/Drivers/AbstractQuantumDriverTest.php b/tests/Unit/Drivers/AbstractQuantumDriverTest.php index 1c79b88..1076ce5 100644 --- a/tests/Unit/Drivers/AbstractQuantumDriverTest.php +++ b/tests/Unit/Drivers/AbstractQuantumDriverTest.php @@ -323,6 +323,45 @@ protected function driverName(): string $this->driver->executeBatch([$circuit]); })->throws(QuantumExecutionException::class, '"results" key'); +it('names the script, the key and the expected shape in a malformed-response message', function () { + $this->bridge->method('execute')->willReturn(['counts' => 'not-an-array']); + + $circuit = $this->createMock(CircuitBuilder::class); + $circuit->method('toArray')->willReturn(['qubits' => 1, 'gates' => [], 'shots' => 1]); + $circuit->method('qubitCount')->willReturn(1); + + expect(fn () => $this->driver->executeCircuit($circuit))->toThrow( + QuantumExecutionException::class, + 'Python script [circuit.py] returned a malformed response: expected the response to have a "counts" key holding an array.' + ); +}); + +it('reports a missing key the same way as a key of the wrong type', function (array $response, string $key) { + $this->bridge->method('execute')->willReturn($response); + + expect(fn () => $this->driver->generateEntropy(8))->toThrow( + QuantumExecutionException::class, + "expected the response to have a \"{$key}\" key holding a string." + ); +})->with([ + 'absent' => [[], 'bits'], + 'null' => [['bits' => null], 'bits'], + 'integer' => [['bits' => 12345], 'bits'], +]); + +it('names the offending batch item when it is not an object', function () { + $this->bridge->method('execute')->willReturn(['results' => [['counts' => ['0' => 1]], 'ok']]); + + $circuit = $this->createMock(CircuitBuilder::class); + $circuit->method('toArray')->willReturn(['qubits' => 1, 'gates' => [], 'shots' => 1]); + $circuit->method('qubitCount')->willReturn(1); + + expect(fn () => $this->driver->executeBatch([$circuit, $circuit]))->toThrow( + QuantumExecutionException::class, + 'expected result #1 to be an object, got string.' + ); +}); + it('throws when a batch.py result lacks a counts array', function () { $this->bridge->method('execute')->willReturn(['results' => [['status' => 'ok']]]); @@ -330,7 +369,7 @@ protected function driverName(): string $circuit->method('toArray')->willReturn(['qubits' => 1, 'gates' => [], 'shots' => 1000]); $this->driver->executeBatch([$circuit]); -})->throws(QuantumExecutionException::class, '"counts" array'); +})->throws(QuantumExecutionException::class, 'expected result #0 to have a "counts" key holding an array'); it('throws when batch.py results count does not match circuits count', function () { $this->bridge->method('execute')->willReturn(['results' => [['counts' => ['0' => 500]]]]); diff --git a/tests/Unit/Tasks/TaskSnapshotTest.php b/tests/Unit/Tasks/TaskSnapshotTest.php index bf6b7aa..bd45a33 100644 --- a/tests/Unit/Tasks/TaskSnapshotTest.php +++ b/tests/Unit/Tasks/TaskSnapshotTest.php @@ -31,3 +31,10 @@ expect($snapshot->status)->toBe(TaskStatus::Queued) ->and($snapshot->counts)->toBeNull(); }); + +it('carries a pre-validated status instead of re-parsing the response', function () { + $snapshot = TaskSnapshot::fromResponse(['status' => 'bogus', 'counts' => ['0' => 10]], TaskStatus::Completed); + + expect($snapshot->status)->toBe(TaskStatus::Completed) + ->and($snapshot->counts)->toBe(['0' => 10]); +});