diff --git a/CLAUDE.md b/CLAUDE.md index bdc8121..7bc1ae5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,8 @@ Quantum (Facade) Published to `config/aether.php`. Key settings: `default` (driver name), `python_path` (Python executable), `drivers` (per-driver config with `synchronous_safe` flag). +Package-level settings are read through `Config\AetherConfig` (a container singleton), never via `config('aether.*')` directly: it owns every top-level default (`DEFAULT_DRIVER = 'local'`, poll interval, attempts, local task TTL...) and returns typed values. Jobs get it by method injection in `handle()`; constructors, `tries()` and drivers resolve it with `app(AetherConfig::class)`. Per-driver options (`aether.drivers.*`) are the driver's own business. + ## Testing `Quantum::fake()` replaces the manager with `QuantumFake` — same pattern as `Http::fake()`. Provides `assertCircuitRan()` and `assertEntropyGenerated()`. diff --git a/README.md b/README.md index a59f1ac..9a57168 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,7 @@ use Aether\Facades\Quantum; Quantum::extend('ionq', fn () => new IonqDriver( Quantum::bridge(), - config('aether.drivers.ionq'), + app(\Aether\Config\AetherConfig::class)->driver('ionq'), )); ``` diff --git a/src/AetherServiceProvider.php b/src/AetherServiceProvider.php index ff0fd5d..f4a637d 100644 --- a/src/AetherServiceProvider.php +++ b/src/AetherServiceProvider.php @@ -4,7 +4,9 @@ namespace Aether; +use Aether\Config\AetherConfig; use Aether\Contracts\QuantumDevice; +use Illuminate\Contracts\Config\Repository; use Illuminate\Foundation\Console\AboutCommand; use Illuminate\Support\ServiceProvider; @@ -20,6 +22,10 @@ public function register(): void 'aether' ); + $this->app->singleton(AetherConfig::class, function ($app): AetherConfig { + return new AetherConfig($app->make(Repository::class)); + }); + $this->app->singleton(QuantumManager::class, function ($app): QuantumManager { return new QuantumManager($app); }); @@ -48,10 +54,14 @@ public function boot(): void $this->commands([Commands\AetherInstallCommand::class]); } - AboutCommand::add('Aether', fn (): array => [ - 'Default Driver' => config('aether.default', 'local'), - 'Python Path' => config('aether.python_path', 'python3'), - 'Process Timeout' => config('aether.process_timeout', 300).'s', - ]); + AboutCommand::add('Aether', function (): array { + $config = $this->app->make(AetherConfig::class); + + return [ + 'Default Driver' => $config->defaultDriver(), + 'Python Path' => $config->pythonPath(), + 'Process Timeout' => $config->processTimeout().'s', + ]; + }); } } diff --git a/src/Commands/AetherInstallCommand.php b/src/Commands/AetherInstallCommand.php index d373a2a..34ae22f 100644 --- a/src/Commands/AetherInstallCommand.php +++ b/src/Commands/AetherInstallCommand.php @@ -4,6 +4,7 @@ namespace Aether\Commands; +use Aether\Config\AetherConfig; use Aether\QuantumManager; use Illuminate\Console\Command; use Symfony\Component\Process\Process; @@ -31,13 +32,13 @@ class AetherInstallCommand extends Command /** * Execute the console command. */ - public function handle(QuantumManager $manager): int + public function handle(QuantumManager $manager, AetherConfig $config): int { $this->components->info('Installing Aether...'); $this->publishConfig(); - $pythonPath = (string) config('aether.python_path', 'python3'); + $pythonPath = $config->pythonPath(); $pythonOk = $this->checkPython($pythonPath); diff --git a/src/Config/AetherConfig.php b/src/Config/AetherConfig.php new file mode 100644 index 0000000..44cf696 --- /dev/null +++ b/src/Config/AetherConfig.php @@ -0,0 +1,162 @@ +set()) is honoured by + * the next read; nothing is snapshotted. + * + * Per-driver options are not covered: they are typed by DriverConfig, built + * by the driver itself from the array driver() returns. + */ +final class AetherConfig +{ + public const DEFAULT_DRIVER = 'local'; + + public const DEFAULT_PYTHON_PATH = 'python3'; + + public const DEFAULT_PROCESS_TIMEOUT = 300; + + public const DEFAULT_POLL_INTERVAL = 5; + + public const DEFAULT_MAX_POLL_ATTEMPTS = 720; + + public const DEFAULT_LOCAL_TASK_TTL = 3600; + + public function __construct( + private readonly Repository $config, + ) {} + + /** + * Name of the driver used when none is given (`aether.default`). + * + * A blank or non-string value falls back to the default, so a stray + * `AETHER_DRIVER=` never resolves to an empty driver name. + */ + public function defaultDriver(): string + { + return $this->string('aether.default') ?? self::DEFAULT_DRIVER; + } + + /** + * Python executable used to run the bin/python scripts (`aether.python_path`). + * + * @deprecated Python support will be removed in the 100% PHP rewrite. + */ + public function pythonPath(): string + { + return $this->string('aether.python_path') ?? self::DEFAULT_PYTHON_PATH; + } + + /** + * Seconds a Python subprocess may run before it is killed (`aether.process_timeout`). + * + * @deprecated Python support will be removed in the 100% PHP rewrite. + */ + public function processTimeout(): int + { + return $this->integer('aether.process_timeout', self::DEFAULT_PROCESS_TIMEOUT); + } + + /** + * Queue the asynchronous jobs run on, or null for the default queue (`aether.queue`). + */ + public function queue(): ?string + { + return $this->string('aether.queue'); + } + + /** + * Seconds between two status checks of an asynchronous task (`aether.poll_interval`). + */ + public function pollInterval(): int + { + return $this->integer('aether.poll_interval', self::DEFAULT_POLL_INTERVAL); + } + + /** + * Number of status checks before the polling job gives up (`aether.max_poll_attempts`). + */ + public function maxPollAttempts(): int + { + return $this->integer('aether.max_poll_attempts', self::DEFAULT_MAX_POLL_ATTEMPTS); + } + + /** + * Whether asynchronous tasks are mirrored into the quantum_tasks table (`aether.persist_tasks`). + * + * Accepts real booleans and the string spellings env() produces; anything + * unrecognised counts as disabled, the safe default for a feature that + * needs a migration to have run. + */ + public function persistTasks(): bool + { + $value = $this->config->get('aether.persist_tasks', false); + + return is_scalar($value) && filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) === true; + } + + /** + * Seconds the local simulator keeps a dispatched result available to the + * polling job (`aether.local_task_ttl`). + */ + public function localTaskTtl(): int + { + return $this->integer('aether.local_task_ttl', self::DEFAULT_LOCAL_TASK_TTL); + } + + /** + * Raw options for one driver (`aether.drivers.`), for the driver to type. + * + * @return array + */ + public function driver(string $name): array + { + $options = $this->config->get("aether.drivers.{$name}", []); + + return is_array($options) ? $options : []; + } + + /** + * Read a string option, trimmed, or null when blank or not a string. + */ + private function string(string $key): ?string + { + $value = $this->config->get($key); + + if (! is_string($value)) { + return null; + } + + $trimmed = trim($value); + + return $trimmed === '' ? null : $trimmed; + } + + /** + * Read an integer option, falling back to $default when blank or not numeric. + */ + private function integer(string $key, int $default): int + { + $value = $this->config->get($key); + + if (is_int($value)) { + return $value; + } + + $filtered = is_scalar($value) && ! is_bool($value) + ? filter_var($value, FILTER_VALIDATE_INT) + : false; + + return $filtered === false ? $default : $filtered; + } +} diff --git a/src/Drivers/LocalSimulatorDriver.php b/src/Drivers/LocalSimulatorDriver.php index 010cf85..ae53a68 100644 --- a/src/Drivers/LocalSimulatorDriver.php +++ b/src/Drivers/LocalSimulatorDriver.php @@ -5,6 +5,7 @@ namespace Aether\Drivers; use Aether\Circuit\CircuitBuilder; +use Aether\Config\AetherConfig; use Aether\Contracts\AsynchronousDevice; use Aether\Exceptions\QuantumExecutionException; use Aether\Tasks\TaskSnapshot; @@ -79,6 +80,6 @@ private function cacheKey(string $taskArn): string private function taskTtl(): int { - return (int) config('aether.local_task_ttl', 3600); + return app(AetherConfig::class)->localTaskTtl(); } } diff --git a/src/Jobs/PollQuantumTask.php b/src/Jobs/PollQuantumTask.php index 9a4c0b2..8e59f5b 100644 --- a/src/Jobs/PollQuantumTask.php +++ b/src/Jobs/PollQuantumTask.php @@ -4,6 +4,7 @@ namespace Aether\Jobs; +use Aether\Config\AetherConfig; use Aether\Contracts\AsynchronousDevice; use Aether\Contracts\QuantumDevice; use Aether\Events\CircuitCompleted; @@ -55,23 +56,28 @@ public function __construct( public readonly array $circuit, public readonly ?string $driver = null, ) { - $this->onQueue(config('aether.queue')); + // Constructors get no method injection, so the queue name is resolved + // from the container by hand; tries() below is in the same position. + $this->onQueue(app(AetherConfig::class)->queue()); } /** * Determine the number of times the job may be attempted. + * + * Called by the queue worker with no arguments, so the setting is read + * from the container rather than injected. */ public function tries(): int { - return (int) config('aether.max_poll_attempts', 720); + return app(AetherConfig::class)->maxPollAttempts(); } /** * Execute the job. */ - public function handle(QuantumManager $manager, Dispatcher $events): void + public function handle(QuantumManager $manager, Dispatcher $events, AetherConfig $config): void { - $driverName = $this->driver ?? config('aether.default', 'local'); + $driverName = $this->driver ?? $config->defaultDriver(); $device = $manager->driver($this->driver); if (! $device instanceof AsynchronousDevice || ! $device instanceof QuantumDevice) { @@ -81,23 +87,23 @@ public function handle(QuantumManager $manager, Dispatcher $events): void $snapshot = $device->checkTask($this->taskArn); if (! $snapshot->status->isTerminal()) { - $maxAttempts = $this->tries(); + $maxAttempts = $config->maxPollAttempts(); if ($this->attempts() >= $maxAttempts) { $e = QuantumExecutionException::pollingExhausted($this->taskArn, $this->attempts()); - $this->persist($snapshot->status, null, $e->getMessage()); + $this->persist($config, $snapshot->status, null, $e->getMessage()); throw $e; } - $this->persist($snapshot->status); - $this->release((int) config('aether.poll_interval', 5)); + $this->persist($config, $snapshot->status); + $this->release($config->pollInterval()); return; } if (! $snapshot->status->isSuccessful()) { $e = TaskFailedException::forTask($this->taskArn, $snapshot->status); - $this->persist($snapshot->status, null, $e->getMessage()); + $this->persist($config, $snapshot->status, null, $e->getMessage()); throw $e; } @@ -106,11 +112,11 @@ public function handle(QuantumManager $manager, Dispatcher $events): void 'checkTask', "task [{$this->taskArn}] completed but returned no measurement counts." ); - $this->persist($snapshot->status, null, $e->getMessage()); + $this->persist($config, $snapshot->status, null, $e->getMessage()); throw $e; } - $this->persist($snapshot->status, $snapshot->counts); + $this->persist($config, $snapshot->status, $snapshot->counts); $events->dispatch(new CircuitCompleted( $driverName, @@ -132,9 +138,9 @@ public function handle(QuantumManager $manager, Dispatcher $events): void * * @param array|null $counts */ - private function persist(TaskStatus $status, ?array $counts = null, ?string $error = null): void + private function persist(AetherConfig $config, TaskStatus $status, ?array $counts = null, ?string $error = null): void { - if (! config('aether.persist_tasks', false)) { + if (! $config->persistTasks()) { return; } diff --git a/src/Jobs/SubmitQuantumCircuit.php b/src/Jobs/SubmitQuantumCircuit.php index 539ded3..5937248 100644 --- a/src/Jobs/SubmitQuantumCircuit.php +++ b/src/Jobs/SubmitQuantumCircuit.php @@ -5,6 +5,7 @@ namespace Aether\Jobs; use Aether\Circuit\CircuitBuilder; +use Aether\Config\AetherConfig; use Aether\Contracts\AsynchronousDevice; use Aether\Contracts\QuantumDevice; use Aether\Exceptions\QuantumExecutionException; @@ -44,15 +45,17 @@ public function __construct( public readonly array $circuit, public readonly ?string $driver = null, ) { - $this->onQueue(config('aether.queue')); + // Constructors get no method injection, so the queue name is the one + // setting resolved from the container by hand. + $this->onQueue(app(AetherConfig::class)->queue()); } /** * Execute the job. */ - public function handle(QuantumManager $manager): void + public function handle(QuantumManager $manager, AetherConfig $config): void { - $driverName = $this->driver ?? config('aether.default', 'local'); + $driverName = $this->driver ?? $config->defaultDriver(); $device = $manager->driver($this->driver); if (! $device instanceof AsynchronousDevice || ! $device instanceof QuantumDevice) { @@ -63,10 +66,10 @@ public function handle(QuantumManager $manager): void $taskArn = $device->submitCircuit($builder); - $this->persistSubmission($taskArn, $driverName); + $this->persistSubmission($config, $taskArn, $driverName); PollQuantumTask::dispatch($taskArn, $this->circuit, $this->driver) - ->delay((int) config('aether.poll_interval', 5)); + ->delay($config->pollInterval()); } /** @@ -77,9 +80,9 @@ public function handle(QuantumManager $manager): void * a database failure is reported and swallowed rather than allowed to * retry the job and submit a second billable task. */ - private function persistSubmission(string $taskArn, string $driverName): void + private function persistSubmission(AetherConfig $config, string $taskArn, string $driverName): void { - if (! config('aether.persist_tasks', false)) { + if (! $config->persistTasks()) { return; } diff --git a/src/QuantumManager.php b/src/QuantumManager.php index b288eaa..6c206cd 100644 --- a/src/QuantumManager.php +++ b/src/QuantumManager.php @@ -7,6 +7,7 @@ use Aether\Bridge\PythonBridge; use Aether\Circuit\BatchBuilder; use Aether\Circuit\CircuitBuilder; +use Aether\Config\AetherConfig; use Aether\Drivers\AwsBraketDriver; use Aether\Drivers\LocalSimulatorDriver; use Aether\Entropy\EntropyGenerator; @@ -32,7 +33,7 @@ class QuantumManager extends Manager */ public function getDefaultDriver(): string { - return $this->config->get('aether.default', 'local'); + return $this->settings()->defaultDriver(); } /** @@ -126,7 +127,7 @@ public function fake(array|CircuitResult|Closure|ResultSequence|null $stub = nul * * Quantum::extend('ionq', fn () => new IonqDriver( * Quantum::bridge(), - * config('aether.drivers.ionq'), + * app(AetherConfig::class)->driver('ionq'), * )); */ public function bridge(): PythonBridge @@ -159,7 +160,7 @@ protected function createLocalDriver(): LocalSimulatorDriver { return new LocalSimulatorDriver( $this->createBridge(), - $this->config->get('aether.drivers.local', []), + $this->settings()->driver('local'), ); } @@ -170,7 +171,7 @@ protected function createAwsDriver(): AwsBraketDriver { return new AwsBraketDriver( $this->createBridge(), - $this->config->get('aether.drivers.aws', []), + $this->settings()->driver('aws'), ); } @@ -179,9 +180,29 @@ protected function createAwsDriver(): AwsBraketDriver */ private function createBridge(): PythonBridge { + $settings = $this->settings(); + return new PythonBridge( - $this->config->get('aether.python_path', 'python3'), - (int) $this->config->get('aether.process_timeout', 300), + $settings->pythonPath(), + $settings->processTimeout(), ); } + + /** + * The typed package settings. + * + * Taken from the container when the service provider has bound it (so a + * swapped instance is honoured), otherwise built over the same config + * repository Manager already holds, so a bare container with only + * `config` bound still works. Resolved per call: the reader is stateless + * and a config value changed after the manager was built must still win. + */ + private function settings(): AetherConfig + { + if ($this->container->bound(AetherConfig::class)) { + return $this->container->make(AetherConfig::class); + } + + return new AetherConfig($this->config); + } } diff --git a/tests/Feature/AsynchronousExecutionTest.php b/tests/Feature/AsynchronousExecutionTest.php index 572da08..63d406b 100644 --- a/tests/Feature/AsynchronousExecutionTest.php +++ b/tests/Feature/AsynchronousExecutionTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Aether\Config\AetherConfig; use Aether\Contracts\PythonExecutor; use Aether\Drivers\LocalSimulatorDriver; use Aether\Events\CircuitCompleted; @@ -51,7 +52,7 @@ $circuit = Quantum::circuit('local')->qubits(1)->h(0)->measure()->shots(100); // Stage one: the submission job hands the task off to the backend. - (new SubmitQuantumCircuit($circuit->toArray(), 'local'))->handle(app(QuantumManager::class)); + (new SubmitQuantumCircuit($circuit->toArray(), 'local'))->handle(app(QuantumManager::class), app(AetherConfig::class)); $arn = null; @@ -65,6 +66,7 @@ (new PollQuantumTask($arn, $circuit->toArray(), 'local'))->handle( app(QuantumManager::class), app(Dispatcher::class), + app(AetherConfig::class), ); Event::assertDispatched(CircuitCompleted::class, function (CircuitCompleted $event) use ($arn): bool { diff --git a/tests/Feature/Jobs/PollQuantumTaskTest.php b/tests/Feature/Jobs/PollQuantumTaskTest.php index e9222c2..6ef6345 100644 --- a/tests/Feature/Jobs/PollQuantumTaskTest.php +++ b/tests/Feature/Jobs/PollQuantumTaskTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Aether\Config\AetherConfig; use Aether\Events\CircuitCompleted; use Aether\Exceptions\QuantumExecutionException; use Aether\Exceptions\TaskFailedException; @@ -11,6 +12,7 @@ use Aether\Tasks\TaskStatus; use Aether\Tests\Feature\Jobs\FakeAsynchronousDevice; use Aether\Tests\Feature\Jobs\FakeSynchronousOnlyDevice; +use Illuminate\Config\Repository; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Queue\Job; use Illuminate\Support\Facades\Event; @@ -29,6 +31,28 @@ expect($job->tries())->toBe(42); }); +it('budgets the attempts inside handle() from the injected settings, not the container', function () { + config(['aether.max_poll_attempts' => 720]); + + $device = new FakeAsynchronousDevice; + $device->snapshotToReturn = new TaskSnapshot(TaskStatus::Running); + + $manager = app(QuantumManager::class); + $manager->extend('fake-async', fn () => $device); + + $mockJob = Mockery::mock(Job::class); + $mockJob->shouldReceive('attempts')->andReturn(2); + $mockJob->shouldNotReceive('release'); + + $job = new PollQuantumTask($device->taskArnToReturn, ['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-async'); + $job->setJob($mockJob); + + $settings = new AetherConfig(new Repository(['aether' => ['max_poll_attempts' => 2]])); + + expect(fn () => $job->handle($manager, app(Dispatcher::class), $settings)) + ->toThrow(QuantumExecutionException::class); +}); + it('releases itself back to the queue with the configured delay when the task is not terminal', function () { config(['aether.poll_interval' => 3, 'aether.max_poll_attempts' => 720]); @@ -39,7 +63,7 @@ $manager->extend('fake-async', fn () => $device); $job = (new PollQuantumTask($device->taskArnToReturn, ['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-async'))->withFakeQueueInteractions(); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(AetherConfig::class)); $job->assertReleased(delay: 3); }); @@ -62,7 +86,7 @@ $job->setJob($mockJob); try { - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(AetherConfig::class)); $this->fail('Expected QuantumExecutionException to be thrown.'); } catch (QuantumExecutionException $exception) { expect($exception->getMessage())->toContain($device->taskArnToReturn); @@ -78,7 +102,7 @@ $job = new PollQuantumTask($device->taskArnToReturn, ['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-async'); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(AetherConfig::class)); })->with([TaskStatus::Failed, TaskStatus::Cancelled])->throws(TaskFailedException::class); it('dispatches CircuitCompleted with the counts and task arn once completed', function () { @@ -92,7 +116,7 @@ $circuit = ['qubits' => 2, 'gates' => [], 'shots' => 10]; $job = new PollQuantumTask($device->taskArnToReturn, $circuit, 'fake-async'); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(AetherConfig::class)); Event::assertDispatched( CircuitCompleted::class, @@ -114,7 +138,7 @@ $manager->extend('fake-async', fn () => $device); $job = new PollQuantumTask($device->taskArnToReturn, ['qubits' => 1, 'gates' => [], 'shots' => 1]); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(AetherConfig::class)); Event::assertDispatched( CircuitCompleted::class, @@ -131,7 +155,7 @@ $job = new PollQuantumTask($device->taskArnToReturn, ['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-async'); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(AetherConfig::class)); })->throws(QuantumExecutionException::class); it('throws asynchronousUnsupported when the resolved driver does not support async execution', function () { @@ -140,5 +164,5 @@ $manager->extend('fake-sync', fn () => $device); $job = new PollQuantumTask('arn:fake', ['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-sync'); - $job->handle($manager, app(Dispatcher::class)); + $job->handle($manager, app(Dispatcher::class), app(AetherConfig::class)); })->throws(QuantumExecutionException::class); diff --git a/tests/Feature/Jobs/SubmitQuantumCircuitTest.php b/tests/Feature/Jobs/SubmitQuantumCircuitTest.php index c662632..5b812a0 100644 --- a/tests/Feature/Jobs/SubmitQuantumCircuitTest.php +++ b/tests/Feature/Jobs/SubmitQuantumCircuitTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Aether\Circuit\CircuitBuilder; +use Aether\Config\AetherConfig; use Aether\Exceptions\QuantumExecutionException; use Aether\Jobs\PollQuantumTask; use Aether\Jobs\SubmitQuantumCircuit; @@ -21,7 +22,7 @@ $manager->extend('fake-async', fn () => $device); $job = new SubmitQuantumCircuit(['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-async'); - $job->handle($manager); + $job->handle($manager, app(AetherConfig::class)); expect($device->submittedCircuits)->toHaveCount(1) ->and($device->submittedCircuits[0])->toBeInstanceOf(CircuitBuilder::class); @@ -40,7 +41,7 @@ $manager->extend('fake-sync', fn () => $device); $job = new SubmitQuantumCircuit(['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-sync'); - $job->handle($manager); + $job->handle($manager, app(AetherConfig::class)); })->throws(QuantumExecutionException::class); it('mentions the unsupported driver name in the exception message', function () { @@ -51,7 +52,7 @@ $job = new SubmitQuantumCircuit(['qubits' => 2, 'gates' => [], 'shots' => 100], 'fake-sync'); try { - $job->handle($manager); + $job->handle($manager, app(AetherConfig::class)); $this->fail('Expected QuantumExecutionException to be thrown.'); } catch (QuantumExecutionException $exception) { expect($exception->getMessage())->toContain('fake-sync'); diff --git a/tests/Feature/PersistenceTest.php b/tests/Feature/PersistenceTest.php index 34e4bc4..4284032 100644 --- a/tests/Feature/PersistenceTest.php +++ b/tests/Feature/PersistenceTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Aether\Config\AetherConfig; use Aether\Events\CircuitCompleted; use Aether\Exceptions\QuantumExecutionException; use Aether\Exceptions\TaskFailedException; @@ -39,7 +40,7 @@ // Submit through the real job, then hand back the poll job it queued so // each test can drive the polling state machine directly. $this->submit = function (): PollQuantumTask { - (new SubmitQuantumCircuit($this->circuit, 'fake-async'))->handle($this->manager); + (new SubmitQuantumCircuit($this->circuit, 'fake-async'))->handle($this->manager, app(AetherConfig::class)); $pollJob = null; Queue::assertPushed(PollQuantumTask::class, function (PollQuantumTask $job) use (&$pollJob) { @@ -51,7 +52,7 @@ return $pollJob; }; - $this->poll = fn (PollQuantumTask $job) => $job->handle($this->manager, app(Dispatcher::class)); + $this->poll = fn (PollQuantumTask $job) => $job->handle($this->manager, app(Dispatcher::class), app(AetherConfig::class)); }); // ------------------------------------------------------------------------- diff --git a/tests/Feature/ServiceProviderTest.php b/tests/Feature/ServiceProviderTest.php index a462f4e..c82792a 100644 --- a/tests/Feature/ServiceProviderTest.php +++ b/tests/Feature/ServiceProviderTest.php @@ -4,16 +4,48 @@ use Aether\AetherServiceProvider; use Aether\Circuit\CircuitBuilder; +use Aether\Config\AetherConfig; use Aether\Contracts\QuantumDevice; +use Aether\Drivers\LocalSimulatorDriver; use Aether\Entropy\EntropyGenerator; use Aether\Facades\Quantum; use Aether\QuantumManager; +use Illuminate\Config\Repository; +use Illuminate\Container\Container; use Illuminate\Support\Facades\Artisan; // ------------------------------------------------------------------------- // Service container registration // ------------------------------------------------------------------------- +it('registers AetherConfig as a singleton in the container', function () { + $first = $this->app->make(AetherConfig::class); + $second = $this->app->make(AetherConfig::class); + + expect($first)->toBeInstanceOf(AetherConfig::class) + ->and($first)->toBe($second); +}); + +it('builds drivers on a bare container that only binds config', function () { + $container = new Container; + $container->instance('config', new Repository(['aether' => ['python_path' => 'python3', 'drivers' => ['local' => []]]])); + + $manager = new QuantumManager($container); + + expect($manager->getDefaultDriver())->toBe('local') + ->and($manager->driver('local'))->toBeInstanceOf(LocalSimulatorDriver::class); +}); + +it('resolves the default driver through AetherConfig', function () { + config()->set('aether.default', ''); + + expect(app(QuantumManager::class)->getDefaultDriver())->toBe('local'); + + config()->set('aether.default', 'aws'); + + expect(app(QuantumManager::class)->getDefaultDriver())->toBe('aws'); +}); + it('registers QuantumManager as a singleton in the container', function () { $first = $this->app->make(QuantumManager::class); $second = $this->app->make(QuantumManager::class); diff --git a/tests/Unit/Config/AetherConfigTest.php b/tests/Unit/Config/AetherConfigTest.php new file mode 100644 index 0000000..d7d88ab --- /dev/null +++ b/tests/Unit/Config/AetherConfigTest.php @@ -0,0 +1,137 @@ + $aether + */ +function aetherConfig(array $aether): AetherConfig +{ + return new AetherConfig(new Repository(['aether' => $aether])); +} + +// ------------------------------------------------------------------------- +// Defaults +// ------------------------------------------------------------------------- + +it('returns the documented defaults when nothing is configured', function () { + $config = aetherConfig([]); + + expect($config->defaultDriver())->toBe('local') + ->and($config->pythonPath())->toBe('python3') + ->and($config->processTimeout())->toBe(300) + ->and($config->queue())->toBeNull() + ->and($config->pollInterval())->toBe(5) + ->and($config->maxPollAttempts())->toBe(720) + ->and($config->persistTasks())->toBeFalse() + ->and($config->localTaskTtl())->toBe(3600) + ->and($config->driver('local'))->toBe([]); +}); + +it('exposes each default as a constant so the literal lives in one place', function () { + expect(AetherConfig::DEFAULT_DRIVER)->toBe('local') + ->and(AetherConfig::DEFAULT_PYTHON_PATH)->toBe('python3') + ->and(AetherConfig::DEFAULT_PROCESS_TIMEOUT)->toBe(300) + ->and(AetherConfig::DEFAULT_POLL_INTERVAL)->toBe(5) + ->and(AetherConfig::DEFAULT_MAX_POLL_ATTEMPTS)->toBe(720) + ->and(AetherConfig::DEFAULT_LOCAL_TASK_TTL)->toBe(3600); +}); + +// ------------------------------------------------------------------------- +// Configured values +// ------------------------------------------------------------------------- + +it('returns the configured values with their documented types', function () { + $config = aetherConfig([ + 'default' => 'aws', + 'python_path' => '/opt/venv/bin/python', + 'process_timeout' => 45, + 'queue' => 'quantum', + 'poll_interval' => 3, + 'max_poll_attempts' => 12, + 'persist_tasks' => true, + 'drivers' => ['aws' => ['region' => 'eu-west-1']], + ]); + + expect($config->defaultDriver())->toBe('aws') + ->and($config->pythonPath())->toBe('/opt/venv/bin/python') + ->and($config->processTimeout())->toBe(45) + ->and($config->queue())->toBe('quantum') + ->and($config->pollInterval())->toBe(3) + ->and($config->maxPollAttempts())->toBe(12) + ->and($config->persistTasks())->toBeTrue() + ->and($config->driver('aws'))->toBe(['region' => 'eu-west-1']); +}); + +it('casts the numeric strings env() hands over', function () { + $config = aetherConfig(['process_timeout' => '45', 'poll_interval' => '3', 'max_poll_attempts' => '12', 'local_task_ttl' => '60']); + + expect($config->processTimeout())->toBe(45) + ->and($config->pollInterval())->toBe(3) + ->and($config->maxPollAttempts())->toBe(12) + ->and($config->localTaskTtl())->toBe(60); +}); + +it('trims the driver name, python path and queue it returns', function () { + $config = aetherConfig(['default' => ' aws ', 'python_path' => ' /usr/bin/python3', 'queue' => 'quantum ']); + + expect($config->defaultDriver())->toBe('aws') + ->and($config->pythonPath())->toBe('/usr/bin/python3') + ->and($config->queue())->toBe('quantum'); +}); + +it('falls back to the default for a blank or non-numeric integer option', function (mixed $raw) { + $config = aetherConfig(['poll_interval' => $raw, 'max_poll_attempts' => $raw, 'process_timeout' => $raw]); + + expect($config->pollInterval())->toBe(5) + ->and($config->maxPollAttempts())->toBe(720) + ->and($config->processTimeout())->toBe(300); +})->with(['null' => [null], 'empty string' => [''], 'word' => ['soon'], 'boolean' => [true], 'array' => [[5]]]); + +it('treats a blank default driver, python path or queue as unset', function (mixed $raw) { + $config = aetherConfig(['default' => $raw, 'python_path' => $raw, 'queue' => $raw]); + + expect($config->defaultDriver())->toBe('local') + ->and($config->pythonPath())->toBe('python3') + ->and($config->queue())->toBeNull(); +})->with(['null' => [null], 'empty string' => [''], 'whitespace' => [' '], 'array' => [['aws']]]); + +it('reads persist_tasks from booleans and their env() spellings', function (mixed $raw, bool $expected) { + expect(aetherConfig(['persist_tasks' => $raw])->persistTasks())->toBe($expected); +})->with([ + 'true' => [true, true], + '"true"' => ['true', true], + '"1"' => ['1', true], + 'false' => [false, false], + '"false"' => ['false', false], + '"0"' => ['0', false], + 'null' => [null, false], + 'garbage' => ['maybe', false], +]); + +it('returns an empty array for a driver entry that is missing or not an array', function () { + $config = aetherConfig(['drivers' => ['aws' => 'oops']]); + + expect($config->driver('aws'))->toBe([]) + ->and($config->driver('ionq'))->toBe([]); +}); + +// ------------------------------------------------------------------------- +// Live reads +// ------------------------------------------------------------------------- + +it('reads the repository on every call instead of snapshotting', function () { + $repository = new Repository(['aether' => ['poll_interval' => 5]]); + $config = new AetherConfig($repository); + + expect($config->pollInterval())->toBe(5); + + $repository->set('aether.poll_interval', 9); + + expect($config->pollInterval())->toBe(9); +}); diff --git a/tests/Unit/Drivers/LocalSimulatorDriverTest.php b/tests/Unit/Drivers/LocalSimulatorDriverTest.php index 8dc0b81..e3175a1 100644 --- a/tests/Unit/Drivers/LocalSimulatorDriverTest.php +++ b/tests/Unit/Drivers/LocalSimulatorDriverTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Aether\Circuit\CircuitBuilder; +use Aether\Config\AetherConfig; use Aether\Contracts\AsynchronousDevice; use Aether\Contracts\EstimatesCost; use Aether\Contracts\PythonExecutor; @@ -46,7 +47,9 @@ // a minimal config repository for the config() helper to resolve. Cache::swap(new CacheRepository(new ArrayStore)); Container::setInstance(tap(new Container, function (Container $container) { - $container->instance('config', new ConfigRepository(['aether' => ['local_task_ttl' => 3600]])); + $config = new ConfigRepository(['aether' => ['local_task_ttl' => 3600]]); + $container->instance('config', $config); + $container->instance(AetherConfig::class, new AetherConfig($config)); })); });