diff --git a/README.md b/README.md index 11db6f1..198ad1c 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended f ## Repositories - [stellarwp/foundation-container](https://github.com/stellarwp/foundation-container) +- [stellarwp/foundation-container-wordpress](https://github.com/stellarwp/foundation-container-wordpress) - [stellarwp/foundation-pipeline](https://github.com/stellarwp/foundation-pipeline) - [stellarwp/foundation-log](https://github.com/stellarwp/foundation-log) - [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) diff --git a/composer.json b/composer.json index 8a27e39..25cd000 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ "ext-curl": "*", "ext-exif": "*", "adbario/php-dot-notation": ">=2.5", - "lucatume/di52": ">=3.0", + "lucatume/di52": ">=4.1", "monolog/monolog": "^2.11", "psr/log": ">=1.0", "stellarwp/container-contract": "^1.1", @@ -25,6 +25,7 @@ "monorepo-php/monorepo": "^12.7", "nunomaduro/collision": "^8.9", "php-mock/php-mock-mockery": "^1.5", + "php-stubs/wordpress-stubs": ">=6.0", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.2", "phpunit/phpunit": "^11.5", @@ -34,6 +35,7 @@ "replace": { "stellarwp/foundation-cli": "self.version", "stellarwp/foundation-container": "self.version", + "stellarwp/foundation-container-wordpress": "self.version", "stellarwp/foundation-log": "self.version", "stellarwp/foundation-pipeline": "self.version", "stellarwp/foundation-wpcli": "self.version" @@ -43,6 +45,7 @@ "autoload": { "psr-4": { "StellarWP\\Foundation\\Cli\\": "src/Cli/", + "StellarWP\\Foundation\\ContainerWordPress\\": "src/ContainerWordPress/", "StellarWP\\Foundation\\Container\\": "src/Container/", "StellarWP\\Foundation\\Log\\": "src/Log/", "StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 44a650b..a1cb3a6 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -3,7 +3,13 @@ parameters: paths: - src - tests + scanFiles: + - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php excludePaths: analyse: - src/*/vendor/* - tests/CodeceptionSupport/* + ignoreErrors: + - + identifier: catch.neverThrown + path: tests/* diff --git a/src/Container/ContainerAdapter.php b/src/Container/ContainerAdapter.php index 91d728a..96bce13 100644 --- a/src/Container/ContainerAdapter.php +++ b/src/Container/ContainerAdapter.php @@ -39,7 +39,7 @@ public function get(string $id): mixed { /** * @codeCoverageIgnore */ - public function get_container(): DI52Container { + public function getContainer(): DI52Container { return $this->container; } @@ -95,6 +95,15 @@ public function instance(mixed $id, array $buildArgs = [], ?array $afterBuildMet return $this->container->instance($id, $buildArgs, $afterBuildMethods); } + /** + * {@inheritDoc} + * + * @throws ContainerException + */ + public function mergeArrayVar(string $id, mixed $implementation): void { + $this->container->mergeArrayVar($id, $implementation); + } + /** * @param class-string|string|object $id * diff --git a/src/Container/Contracts/Container.php b/src/Container/Contracts/Container.php index 5502a94..3c1aca2 100644 --- a/src/Container/Contracts/Container.php +++ b/src/Container/Contracts/Container.php @@ -37,6 +37,15 @@ public function needs(string $id): Container; public function give(mixed $implementation): void; + /** + * Add array values to an existing or future binding without replacing previous values. + * + * @param class-string|string $id + * + * @throws \lucatume\DI52\ContainerException + */ + public function mergeArrayVar(string $id, mixed $implementation): void; + /** * Returns a callable object (Closure) that will build an instance of the specified * class using the specified arguments when called. diff --git a/src/Container/composer.json b/src/Container/composer.json index 9e083a6..06734e7 100644 --- a/src/Container/composer.json +++ b/src/Container/composer.json @@ -10,7 +10,7 @@ "require": { "php": ">=8.3", "adbario/php-dot-notation": ">=2.5", - "lucatume/di52": ">=3.0", + "lucatume/di52": ">=4.1", "stellarwp/container-contract": "^1.1", "vlucas/phpdotenv": ">=4.3" }, diff --git a/src/ContainerWordPress/.gitattributes b/src/ContainerWordPress/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/ContainerWordPress/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/ContainerWordPress/.github/workflows/close-pull-request.yml b/src/ContainerWordPress/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..6bfbabe --- /dev/null +++ b/src/ContainerWordPress/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/ContainerWordPress/.gitignore b/src/ContainerWordPress/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/ContainerWordPress/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/ContainerWordPress/ContainerAdapter.php b/src/ContainerWordPress/ContainerAdapter.php new file mode 100644 index 0000000..98c12c8 --- /dev/null +++ b/src/ContainerWordPress/ContainerAdapter.php @@ -0,0 +1,258 @@ + $decorators, ?array $afterBuildMethods = null) + * @method void bindDecorators($id, array $decorators, ?array $afterBuildMethods = null) + */ +final readonly class ContainerAdapter implements Container +{ + private FoundationContainerAdapter $container; + + private string $prefix; + + public function __construct( + FoundationContainerAdapter $container, + string $prefix = 'nexcess/foundation/container/wp/', + ) { + $this->container = $container; + $this->prefix = $prefix === '' ? '' : rtrim($prefix, '/') . '/'; + } + + /** + * Build the "registered" WordPress action name for a service provider or alias. + * + * @param string $identifier The service provider class or alias slug. + * + * @throws InvalidArgumentException When the provided $identifier is empty. + * + * @return non-empty-string + */ + private function registeredAction(string $identifier): string { + if ($identifier === '') { + throw new InvalidArgumentException('You need to provide an identifier!'); + } + + return $this->prefix . $identifier . '/registered'; + } + + /** + * {@inheritDoc} + */ + public function register(string $serviceProviderClass, ...$alias): void { + $this->registeredAction($serviceProviderClass); + + foreach ($alias as $slug) { + $this->registeredAction($slug); + } + + $this->container->register($serviceProviderClass, ...$alias); + + /** + * Fires after a service provider has been registered in the container. + * + * The dynamic portion of the hook name, `$serviceProviderClass`, refers to the + * fully-qualified class name of the registered service provider. + * + * @param class-string $serviceProviderClass The registered service provider class. + * @param string[] $alias The aliases the provider was registered under. + */ + do_action($this->registeredAction($serviceProviderClass), $serviceProviderClass, $alias); + + foreach ($alias as $slug) { + /** + * Fires after a service provider has been registered, once per alias. + * + * The dynamic portion of the hook name, `$slug`, refers to an alias the + * service provider was registered under. + * + * @param class-string $serviceProviderClass The registered service provider class. + * @param string[] $alias The aliases the provider was registered under. + */ + do_action($this->registeredAction($slug), $serviceProviderClass, $alias); + } + } + + /** + * {@inheritDoc} + */ + public function registerAfterAllActions(array $actions, string $serviceProviderClass, ...$alias): void { + $pending = array_values(array_filter($actions, static fn (string $action): bool => ! did_action($action))); + + if ($pending === []) { + // All the actions have already fired, register the provider immediately. + $this->register($serviceProviderClass, ...$alias); + + return; + } + + // A single closure is hooked onto every pending action. Whichever action fires + // last finds all actions done, registers once, and detaches from the rest. + $register_when_ready = function () use ($actions, $pending, $serviceProviderClass, $alias, &$register_when_ready): void { + foreach ($actions as $action) { + if (! did_action($action)) { + return; + } + } + + // Detach from every pending action so the provider is only registered once. + foreach ($pending as $action) { + remove_action($action, $register_when_ready); + } + + $this->register($serviceProviderClass, ...$alias); + }; + + foreach ($pending as $action) { + add_action($action, $register_when_ready); + } + } + + /** + * {@inheritDoc} + */ + public function registerOnAction(string $action, string $serviceProviderClass, ...$alias): void { + if (did_action($action)) { + // If the action has already fired, register the provider immediately. + $this->register($serviceProviderClass, ...$alias); + + return; + } + + // If the action has not fired yet, register the provider when/if it does. + $registration_closure = function () use ($action, $serviceProviderClass, $alias, &$registration_closure) { + // Remove the closure from the action to avoid calling it again. + remove_action($action, $registration_closure); + $this->register($serviceProviderClass, ...$alias); + }; + + add_action($action, $registration_closure); + } + + /** + * {@inheritDoc} + */ + public function registerOnProvider( + string $baseProviderClass, + string $dependentProviderClass, + ...$alias + ): void { + $this->registerOnAction($this->registeredAction($baseProviderClass), $dependentProviderClass, ...$alias); + } + + /** + * {@inheritDoc} + */ + public function when(string $class): Container { + $this->container->when($class); + + return $this; + } + + /** + * {@inheritDoc} + */ + public function needs(string $id): Container { + $this->container->needs($id); + + return $this; + } + + /** + * @param string[]|null $afterBuildMethods + * + * @throws \lucatume\DI52\ContainerException + */ + public function bind(string $id, mixed $implementation = null, ?array $afterBuildMethods = null): void { + $this->container->bind($id, $implementation, $afterBuildMethods); + } + + /** + * {@inheritDoc} + */ + public function get(string $id): mixed { + return $this->container->get($id); + } + + /** + * @codeCoverageIgnore + */ + public function getContainer(): DI52Container { + return $this->container->getContainer(); + } + + /** + * {@inheritDoc} + * + * @codeCoverageIgnore + */ + public function has(string $id): bool { + return $this->container->has($id); + } + + /** + * @param string[]|null $afterBuildMethods + * + * @throws \lucatume\DI52\ContainerException + */ + public function singleton(string $id, mixed $implementation = null, ?array $afterBuildMethods = null): void { + $this->container->singleton($id, $implementation, $afterBuildMethods); + } + + public function give(mixed $implementation): void { + $this->container->give($implementation); + } + + /** + * {@inheritDoc} + * + * @throws ContainerException + */ + public function mergeArrayVar(string $id, mixed $implementation): void { + $this->container->mergeArrayVar($id, $implementation); + } + + /** + * @param array $buildArgs + * @param string[]|null $afterBuildMethods + */ + public function instance(mixed $id, array $buildArgs = [], ?array $afterBuildMethods = null): Closure { + return $this->container->instance($id, $buildArgs, $afterBuildMethods); + } + + /** + * @param class-string|string|object $id + * + * @throws ContainerException + */ + public function callback(object|string $id, string $method): callable { + return $this->container->callback($id, $method); + } + + /** + * Defer all other calls to the wrapped Foundation container adapter. + * + * @param string $name The method name. + * @param mixed[] $args Method arguments. + */ + public function __call(string $name, array $args): mixed { + return $this->container->{$name}(...$args); + } +} diff --git a/src/ContainerWordPress/Contracts/Container.php b/src/ContainerWordPress/Contracts/Container.php new file mode 100644 index 0000000..054ea82 --- /dev/null +++ b/src/ContainerWordPress/Contracts/Container.php @@ -0,0 +1,60 @@ + $serviceProviderClass The Service Provider to register on $action. + * @param string $alias Alias(es) for the $serviceProviderClass. + * + * @throws \lucatume\DI52\ContainerException + */ + public function registerOnAction(string $action, string $serviceProviderClass, ...$alias): void; + + /** + * @param class-string|string $baseProviderClass The provider class or id that the $dependentProviderClass + * depends on. + * @param class-string $dependentProviderClass The Service Provider to register after + * $baseProviderClass has been registered. + * @param string ...$alias Alias(es) for the $dependentProviderClass. + * + * @throws \lucatume\DI52\ContainerException + */ + public function registerOnProvider(string $baseProviderClass, string $dependentProviderClass, ...$alias): void; + + /** + * @param list $actions A list of actions that all need to be fired for the Provider to be + * registered. + * @param class-string $serviceProviderClass The Service Provider to register when the last action from $actions + * is fired. + * @param string ...$alias Alias(es) for the $serviceProviderClass. + * + * @throws \lucatume\DI52\ContainerException + */ + public function registerAfterAllActions(array $actions, string $serviceProviderClass, ...$alias): void; +} diff --git a/src/ContainerWordPress/README.md b/src/ContainerWordPress/README.md new file mode 100644 index 0000000..3d944d7 --- /dev/null +++ b/src/ContainerWordPress/README.md @@ -0,0 +1,143 @@ +# Foundation Container WordPress + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +A WordPress-focused wrapper around [stellarwp/foundation-container](https://github.com/stellarwp/foundation-container). +It exposes the full Foundation DI container API and adds WordPress-specific helpers. + +## Installation + +```shell +composer require stellarwp/foundation-container-wordpress +``` + +## Usage + +Create a new `ContainerAdapter`, passing in a Foundation container adapter. It implements the +[`Contracts/Container.php`](./Contracts/Container.php) interface, which extends the base +Foundation container contract: + +```php +bind(ContainerContract::class, $container); +$container->bind(WPContainerContract::class, $container); +``` + +Everything the [Foundation Container](https://github.com/stellarwp/foundation-container) can do, +this wrapper can do too — binding, singletons, service providers, contextual bindings, and so on. + +## WordPress helpers + +On top of the base container API, this wrapper adds hook-aware service provider +registration. These methods are declared on +[`Contracts/Container.php`](./Contracts/Container.php) and implemented in +[`ContainerAdapter.php`](./ContainerAdapter.php). + +All of them accept the same optional trailing `...$alias` arguments as the base +`register()` method. + +## Hook Prefix + +The WordPress container adapter will fire registration hooks when a Provider is being registered. By default, it uses +`'nexcess/foundation/container/wp/'` as the hook prefix, but you can change that by passing a second argument during the +adapter's initialization. The adapter normalizes non-empty prefixes to include one trailing slash. + +```php +$container = new ContainerAdapter(new FoundationContainerAdapter(new Container()), 'my/hook/prefix/'); + +add_action( + 'my/hook/prefix/' . My_Provider::class . '/registered', + function ( string $provider_class, array $aliases ): void { + // React to My_Provider having been registered. + }, + 10, + 2 +); + +// Or by using its alias. +add_action( + 'my/hook/prefix/my-alias/registered', + function ( string $provider_class, array $aliases ): void { + // React to My_Provider having been registered. + }, + 10, + 2 +); + +$container->register( My_Provider::class, 'my-alias' ); + + +``` + +### Registration actions + +`register()` is overridden so that, once a provider has been registered, it fires +WordPress actions other code can hook onto: + +| Action | Fired | +|----------------------------------------------------------------------| --- | +| `nexcess/foundation/container/wp/{$serviceProviderClass}/registered` | Once, for the registered provider class. | +| `nexcess/foundation/container/wp/{$alias}/registered` | Once per alias the provider was registered under. | + +Both actions pass two arguments to listeners: the registered provider class +(`string`) and the list of aliases (`string[]`). + +```php +add_action( + 'nexcess/foundation/container/wp/' . My_Provider::class . '/registered', + function ( string $provider_class, array $aliases ): void { + // React to My_Provider having been registered. + }, + 10, + 2 +); + +$container->register( My_Provider::class, 'my-alias' ); +``` + +### `registerOnAction()` + +Register a provider when a WordPress action fires. If the action has already +fired, the provider is registered immediately; otherwise registration is deferred +until the action fires and happens only once. + +```php +// Register when `init` fires (or right away if it already has). +$container->registerOnAction( 'init', My_Provider::class ); +``` + +### `registerOnProvider()` + +Register a provider only after another provider has been registered. It builds on +the `.../registered` action above, so the dependent provider is wired up as soon +as the base provider is registered. + +```php +// Register Feature_Provider after Core_Provider has been registered. +$container->registerOnProvider( Core_Provider::class, Feature_Provider::class ); +``` + +### `registerAfterAllActions()` + +Register a provider only after *every* one of the given actions has fired. If all +of them have already fired, registration happens immediately; otherwise it waits +for the last one and then registers exactly once. + +```php +// Register once both `plugins_loaded` and `init` have fired. +$container->registerAfterAllActions( [ 'plugins_loaded', 'init' ], My_Provider::class ); +``` diff --git a/src/ContainerWordPress/composer.json b/src/ContainerWordPress/composer.json new file mode 100644 index 0000000..a71c108 --- /dev/null +++ b/src/ContainerWordPress/composer.json @@ -0,0 +1,24 @@ +{ + "name": "stellarwp/foundation-container-wordpress", + "type": "library", + "description": "The Foundation DI container wrapper for WordPress projects.", + "license": "GPL-2.0-or-later", + "config": { + "vendor-dir": "vendor", + "preferred-install": "dist" + }, + "require": { + "php": ">=8.3", + "stellarwp/foundation-container": "^1.2" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\ContainerWordPress\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "1.2.x-dev" + } + } +} diff --git a/tests/Support/Fixtures/ContainerWordPress/FirstProvider.php b/tests/Support/Fixtures/ContainerWordPress/FirstProvider.php new file mode 100644 index 0000000..e405239 --- /dev/null +++ b/tests/Support/Fixtures/ContainerWordPress/FirstProvider.php @@ -0,0 +1,20 @@ +assertSame('fallback', $adapter->getVar('missing', 'fallback')); } + + public function test_it_merges_array_values_across_multiple_calls(): void { + $adapter = new ContainerAdapter(new DI52Container()); + + $adapter->mergeArrayVar('list', ['a']); + $adapter->mergeArrayVar('list', ['b', 'c']); + + $this->assertSame(['a', 'b', 'c'], $adapter->getVar('list')); + } + + public function test_it_merges_associative_array_values_without_replacing_previous_ones(): void { + $adapter = new ContainerAdapter(new DI52Container()); + + $adapter->mergeArrayVar('config', ['x' => 1]); + $adapter->mergeArrayVar('config', ['y' => 2]); + + $this->assertSame(['x' => 1, 'y' => 2], $adapter->getVar('config')); + } + + public function test_it_throws_when_merging_into_an_already_resolved_singleton(): void { + $adapter = new ContainerAdapter(new DI52Container()); + + $adapter->singleton('resolved', static fn (): array => ['first']); + $adapter->get('resolved'); + + $this->expectException(ContainerException::class); + + $adapter->mergeArrayVar('resolved', ['second']); + } } diff --git a/tests/Unit/ContainerWordPress/ContainerAdapterTest.php b/tests/Unit/ContainerWordPress/ContainerAdapterTest.php new file mode 100644 index 0000000..dcfdaeb --- /dev/null +++ b/tests/Unit/ContainerWordPress/ContainerAdapterTest.php @@ -0,0 +1,91 @@ +assertInstanceOf(Container::class, $this->make_adapter()); + } + + public function test_it_binds_and_resolves_through_the_wrapped_container(): void { + $adapter = $this->make_adapter(); + $adapter->bind(ContainerAdapterSample::class, static fn (): ContainerAdapterSample => new ContainerAdapterSample('value')); + + $this->assertSame('value', $adapter->get(ContainerAdapterSample::class)->value); + } + + public function test_it_returns_instance_builders_from_the_wrapped_container(): void { + $adapter = $this->make_adapter(); + $factory = $adapter->instance(ContainerAdapterSample::class, ['value']); + + $this->assertInstanceOf(ContainerAdapterSample::class, $factory()); + $this->assertSame('value', $factory()->value); + } + + public function test_it_returns_callbacks_from_the_wrapped_container(): void { + $adapter = $this->make_adapter(); + $callback = $adapter->callback(new ContainerAdapterSample('value'), 'read'); + + $this->assertSame('value', $callback()); + } + + public function test_it_exposes_contextual_bindings_fluently(): void { + $adapter = $this->make_adapter(); + + $this->assertSame($adapter, $adapter->when(ContainerAdapterSample::class)); + $this->assertSame($adapter, $adapter->needs('$value')); + } + + public function test_it_forwards_unknown_method_calls_to_the_wrapped_container(): void { + $this->assertSame('fallback', $this->make_adapter()->getVar('missing', 'fallback')); + } + + public function test_it_exposes_the_underlying_di52_container(): void { + $di52 = new DI52Container(); + $adapter = new ContainerAdapter(new FoundationContainerAdapter($di52)); + + $this->assertSame($di52, $adapter->getContainer()); + } + + public function test_it_merges_array_values_through_the_wrapped_container(): void { + $adapter = $this->make_adapter(); + + $adapter->mergeArrayVar('list', ['a']); + $adapter->mergeArrayVar('list', ['b', 'c']); + + $this->assertSame(['a', 'b', 'c'], $adapter->getVar('list')); + } + + public function test_it_merges_associative_array_values_without_replacing_previous_ones(): void { + $adapter = $this->make_adapter(); + + $adapter->mergeArrayVar('config', ['x' => 1]); + $adapter->mergeArrayVar('config', ['y' => 2]); + + $this->assertSame(['x' => 1, 'y' => 2], $adapter->getVar('config')); + } + + public function test_it_throws_when_merging_into_an_already_resolved_singleton(): void { + $adapter = $this->make_adapter(); + + $adapter->singleton('resolved', static fn (): array => ['first']); + $adapter->get('resolved'); + + $this->expectException(ContainerException::class); + + $adapter->mergeArrayVar('resolved', ['second']); + } +} diff --git a/tests/wpunit/ContainerWordPress/ContainerAdapterTest.php b/tests/wpunit/ContainerWordPress/ContainerAdapterTest.php new file mode 100644 index 0000000..08c212e --- /dev/null +++ b/tests/wpunit/ContainerWordPress/ContainerAdapterTest.php @@ -0,0 +1,268 @@ +adapter = new ContainerAdapter(new FoundationContainerAdapter(new DI52Container())); + + $this->adapter->bind(FoundationContainer::class, $this->adapter); + $this->adapter->bind(WPContainerContract::class, $this->adapter); + } + + /** + * Build the "registered" action name the adapter fires for a provider or alias. + */ + private function registered_action(string $identifier): string { + return 'nexcess/foundation/container/wp/' . $identifier . '/registered'; + } + + private function adapter_with_prefix(string $prefix): ContainerAdapter { + $adapter = new ContainerAdapter(new FoundationContainerAdapter(new DI52Container()), $prefix); + + $adapter->bind(FoundationContainer::class, $adapter); + $adapter->bind(WPContainerContract::class, $adapter); + + return $adapter; + } + + /** + * Count how many times a WordPress action fires while a callback is attached. + */ + private function count_action(string $action): callable { + $original = did_action($action); + + return static fn (): int => did_action($action) - $original; + } + + public function test_register_fires_a_registered_action_for_the_provider(): void { + $fired = $this->count_action($this->registered_action(FirstProvider::class)); + + $this->adapter->register(FirstProvider::class); + + $this->assertSame(1, $fired()); + + $this->adapter->register(FirstProvider::class); + + $this->assertSame(2, $fired()); + } + + public function test_register_fires_a_registered_action_for_each_alias(): void { + $provider = $this->count_action($this->registered_action(FirstProvider::class)); + $alpha = $this->count_action($this->registered_action('alpha')); + $beta = $this->count_action($this->registered_action('beta')); + + $this->adapter->register(FirstProvider::class, 'alpha', 'beta'); + + $this->assertSame(1, $provider()); + $this->assertSame(1, $alpha()); + $this->assertSame(1, $beta()); + + $this->adapter->register(FirstProvider::class, 'alpha', 'beta'); + + $this->assertSame(2, $provider()); + $this->assertSame(2, $alpha()); + $this->assertSame(2, $beta()); + } + + public function test_register_normalizes_custom_prefixes_to_one_trailing_slash(): void { + foreach (['custom/foundation/container', 'custom/foundation/container/', 'custom/foundation/container///'] as $prefix) { + $adapter = $this->adapter_with_prefix($prefix); + $fired = $this->count_action('custom/foundation/container/' . FirstProvider::class . '/registered'); + + $adapter->register(FirstProvider::class); + + $this->assertSame(1, $fired()); + } + } + + public function test_register_allows_an_empty_custom_prefix(): void { + $adapter = $this->adapter_with_prefix(''); + $fired = $this->count_action(FirstProvider::class . '/registered'); + + $adapter->register(FirstProvider::class); + + $this->assertSame(1, $fired()); + } + + public function test_register_validates_aliases_before_registering_the_provider(): void { + $fired = $this->count_action($this->registered_action(FirstProvider::class)); + + try { + $this->adapter->register(FirstProvider::class, ''); + + $this->fail('Expected an invalid alias to throw before provider registration.'); + } catch (InvalidArgumentException) { + $this->assertSame(0, $fired()); + } + } + + public function test_register_passes_the_provider_class_and_aliases_to_listeners(): void { + $received = []; + + add_action( + $this->registered_action(FirstProvider::class), + static function ($serviceProviderClass = null, $alias = null) use (&$received): void { + $received = ['provider' => $serviceProviderClass, 'alias' => $alias]; + }, + 10, + 2 + ); + + $this->adapter->register(FirstProvider::class, 'alpha', 'beta'); + + $this->assertSame(FirstProvider::class, $received['provider']); + $this->assertSame(['alpha', 'beta'], $received['alias']); + } + + public function test_register_passes_the_provider_class_and_aliases_to_alias_listeners(): void { + $received = []; + + add_action( + $this->registered_action('alpha'), + static function ($serviceProviderClass = null, $alias = null) use (&$received): void { + $received = ['provider' => $serviceProviderClass, 'alias' => $alias]; + }, + 10, + 2 + ); + + $this->adapter->register(FirstProvider::class, 'alpha', 'beta'); + + $this->assertSame(FirstProvider::class, $received['provider']); + $this->assertSame(['alpha', 'beta'], $received['alias']); + } + + public function test_register_on_action_registers_immediately_when_the_action_already_fired(): void { + $fired = $this->count_action($this->registered_action(FirstProvider::class)); + + do_action('foundation_boot_done'); + + $this->assertSame(0, $fired()); + + $this->adapter->registerOnAction('foundation_boot_done', FirstProvider::class); + + $this->assertSame(1, $fired()); + } + + public function test_register_on_action_defers_registration_until_the_action_fires(): void { + $fired = $this->count_action($this->registered_action(FirstProvider::class)); + + $this->adapter->registerOnAction('foundation_boot_pending', FirstProvider::class); + $this->assertSame(0, $fired()); + + do_action('foundation_boot_pending'); + $this->assertSame(1, $fired()); + } + + public function test_register_on_action_only_registers_once_even_if_the_action_fires_again(): void { + $fired = $this->count_action($this->registered_action(FirstProvider::class)); + + $this->adapter->registerOnAction('foundation_boot_repeat', FirstProvider::class); + + do_action('foundation_boot_repeat'); + + $this->assertSame(1, $fired()); + do_action('foundation_boot_repeat'); + + $this->assertSame(1, $fired()); + } + + public function test_register_after_provider_registers_the_dependent_once_the_base_registers(): void { + $base = $this->count_action($this->registered_action(FirstProvider::class)); + $dependent = $this->count_action($this->registered_action(SecondProvider::class)); + + $this->adapter->registerOnProvider(FirstProvider::class, SecondProvider::class); + $this->assertSame(0, $base()); + $this->assertSame(0, $dependent()); + + $this->adapter->register(FirstProvider::class); + + $this->assertSame(1, $base()); + $this->assertSame(1, $dependent()); + } + + public function test_register_after_all_actions_registers_immediately_when_all_actions_are_done(): void { + $fired = $this->count_action($this->registered_action(FirstProvider::class)); + + do_action('foundation_all_first'); + do_action('foundation_all_second'); + + $this->adapter->registerAfterAllActions( + ['foundation_all_first', 'foundation_all_second'], + FirstProvider::class + ); + + $this->assertSame(1, $fired()); + + do_action('foundation_all_first'); + do_action('foundation_all_second'); + + $this->assertSame(1, $fired()); + } + + public function test_register_after_all_actions_defers_until_the_pending_action_fires(): void { + $fired = $this->count_action($this->registered_action(FirstProvider::class)); + + $this->adapter->registerAfterAllActions(['foundation_all_pending'], FirstProvider::class); + $this->assertSame(0, $fired()); + + do_action('foundation_all_pending'); + $this->assertSame(1, $fired()); + } + + public function test_register_after_all_actions_waits_for_every_action_before_registering(): void { + $fired = $this->count_action($this->registered_action(FirstProvider::class)); + + $this->adapter->registerAfterAllActions( + ['foundation_multi_first', 'foundation_multi_second'], + FirstProvider::class + ); + $this->assertSame(0, $fired()); + + do_action('foundation_multi_first'); + $this->assertSame(0, $fired()); + + do_action('foundation_multi_second'); + $this->assertSame(1, $fired()); + + do_action('foundation_multi_first'); + $this->assertSame(1, $fired()); + + do_action('foundation_multi_second'); + $this->assertSame(1, $fired()); + } + + public function test_register_after_all_actions_registers_once_when_actions_fire_repeatedly(): void { + $fired = $this->count_action($this->registered_action(FirstProvider::class)); + + $this->adapter->registerAfterAllActions( + ['foundation_repeat_first', 'foundation_repeat_second'], + FirstProvider::class + ); + + do_action('foundation_repeat_first'); + do_action('foundation_repeat_second'); + $this->assertSame(1, $fired()); + + // Firing the same actions again must not register the provider a second time. + do_action('foundation_repeat_first'); + do_action('foundation_repeat_second'); + $this->assertSame(1, $fired()); + } +}