diff --git a/tests/README.md b/tests/README.md index dee0b20..51be93e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -715,3 +715,99 @@ sequenceDiagram Rw->>Rw: screen is plugins, arg names a registered standalone, nonce verifies Rw-->>WP: core's sentence swapped for the host's, wrapper untouched ``` + +#### `Scenario/HostTest.php` — the host's own wiring + +Not what the library does with a sub-plugin, but what it does with the *host*: +that booting late still works, and that a host's own implementations are the +objects the request actually reaches. + +**Where a host may bind, and when.** These scenarios are the worked example of a +rule that is easy to get wrong, so they show both halves side by side: + +```mermaid +flowchart TD + A[host builds a container] --> B{interface id or concrete class id?} + B -- "interface, e.g. Resolver_Interface" --> C[bind BEFORE boot] + B -- "class, e.g. Conflict Gatekeeper" --> D[bind AFTER boot] + C --> E[Provider skips it: nothing can build an interface unprompted, so has is true only when bound] + D --> F[Provider would overwrite it: di52 answers has true for any class that exists, bound or not] + E --> G[the request reaches the host's object] + F --> G +``` + +Binding a class after `boot()` still works because `Boot\Scheduler` wires +closures that resolve when the hook fires rather than objects built at boot — so +a host may rebind right up until `plugins_loaded`. `tests/unit/ProviderTest.php` +states the same rule from the provider's side. + +**A host that boots too late still gets its sub-plugins.** Booting from +`plugins_loaded` at the default priority is the commonest hook mistake there is, +and an `add_action()` at a priority the running dispatch has already passed is +accepted and then never fires. The library reports the mistake through +`_doing_it_wrong()` and runs the sequence inline, so the site still gets its +bundled plugins. + +```mermaid +sequenceDiagram + autonumber + participant WP as WordPress + participant Abs as Absorber + participant L as Loader + + WP->>WP: plugins_loaded begins dispatching + WP->>Abs: host calls boot() at priority 10 — too late to wire 5 or 6 + Abs->>Abs: reports incorrect usage + Abs->>L: runs the whole sequence inline, in hook order + L-->>WP: the bundled plugin is loaded anyway +``` + +**A host binding reaches every step of the request.** Five interface seams bound +before boot — registrar, plugin checker, plugin deactivator, notice writer, +activator — each asserted to be the object the request used. The defaults are +asserted *not* to have run beside them: a library that quietly resolved a second +copy behind the host's back would satisfy every positive assertion here. Two +requests, because a DEACTIVATE resolution ends in a redirect and `exit`, so the +load pass belongs to the request after it. + +```mermaid +sequenceDiagram + autonumber + participant Host as Host container + participant WP as WordPress + participant Spy as The host's objects + participant Def as The library's defaults + + Host->>Host: binds 5 interface ids, then boot() + WP->>Spy: request one — checker, deactivator, writer + Spy-->>WP: redirect and exit + WP->>Spy: request two — registrar, activator, the load + Note over Def: active_plugins untouched, both options unwritten + Def-->>Def: never resolved at all +``` + +**A host binding replaces the gatekeeper and the resolver.** The same guarantee +for the two the conflict step resolves itself. A host owns what a conflict +*means* — but not who may have one resolved, which is why the gate is asked +first, separately, and is asserted here to have been asked at all. Both gates +are counted, because they are asked at different moments and a single counter +would read "never got past the request gate" as "passed both". + +```mermaid +sequenceDiagram + autonumber + participant Host as Host container + participant G as The host's gatekeeper + participant R as The host's resolver + participant L as Loader + + Host->>Host: binds Resolver_Interface, then boot(), then Gatekeeper + Host->>G: request_may_resolve() + G-->>Host: true + Host->>Host: a standalone is active — there is a conflict + Host->>G: user_may_resolve() + G-->>Host: true + Host->>R: resolve_all() — this host's does nothing + Note over R: so nothing is deactivated and nothing is queued + Host->>L: the load pass still runs behind it +``` diff --git a/tests/_support/Spy_Gatekeeper.php b/tests/_support/Spy_Gatekeeper.php new file mode 100644 index 0000000..cb12192 --- /dev/null +++ b/tests/_support/Spy_Gatekeeper.php @@ -0,0 +1,76 @@ +user_may_resolve_calls` off a + * value typed as `Gatekeeper` is reading a property the class does not declare, and static analysis + * rightly rejects it. Named, the spy's own type carries the counters. + * + * A subclass rather than an implementation of a contract, because there is no contract: the + * gatekeeper is bound by class name, which is the seam a host rebinds and a test extends. The parent + * has no constructor of its own, so nothing is left unbuilt by not calling one. + * + * Both gates are counted separately, because they are asked at different moments and one of them may + * not be reached at all — the request gate runs first and turns away everything that is not an + * interactive admin GET, while the capability gate is asked only once a conflict is known to exist. + * A single counter would read a request that never got past the first gate as one that passed both. + * + * @since 1.0.0 + */ +class Spy_Gatekeeper extends Gatekeeper { + /** + * How many times request_may_resolve() was called. + * + * @var int + */ + public $request_may_resolve_calls = 0; + + /** + * How many times user_may_resolve() was called. + * + * @var int + */ + public $user_may_resolve_calls = 0; + + /** + * What both gates answer. + * + * @var bool + */ + private $may_resolve; + + /** + * @param bool $may_resolve What both gates answer, for the life of this spy. + */ + public function __construct( bool $may_resolve ) { + $this->may_resolve = $may_resolve; + } + + /** + * @return bool + */ + public function request_may_resolve(): bool { + ++$this->request_may_resolve_calls; + + return $this->may_resolve; + } + + /** + * @return bool + */ + public function user_may_resolve(): bool { + ++$this->user_may_resolve_calls; + + return $this->may_resolve; + } +} diff --git a/tests/unit/Scenario/HostTest.php b/tests/unit/Scenario/HostTest.php new file mode 100644 index 0000000..3cdd2c6 --- /dev/null +++ b/tests/unit/Scenario/HostTest.php @@ -0,0 +1,269 @@ +expect_incorrect_usage(); + + $constant = $this->register(); + + $this->add_tracked_action( + 'plugins_loaded', + function (): void { + $this->boot(); + } + ); + + $this->run_request(); + + $this->assertSame( 1, $this->bundled_plugin_loads(), 'A late boot must still load.' ); + $this->assertTrue( defined( $constant ) ); + $this->assert_the_library_reported_incorrect_usage(); + } + + /** + * The whole point of a required container: a host binds its own implementation of an interface + * before boot, and that is the object the library uses for the rest of the request. Every id here + * is an interface, which is what makes binding first enough — nothing can build one unprompted, so + * `Provider` sees the host's binding and stands down. The concrete-class half of that rule is the + * scenario below. + * + * Two requests, because a deactivation ends the first one where production ends it: the resolver + * redirects so that the standalone's code is out of memory, and the load pass runs on the request + * after. The host's checker reports the standalone gone on that second one, exactly as the default + * reading `active_plugins` would. The defaults are asserted *not* to have run alongside the + * doubles — a library that resolved a second copy of the notice writer or the deactivator behind + * the host's back would satisfy every positive assertion here. + */ + public function test_a_host_binding_reaches_every_step_of_the_request(): void { + $registrar = new Spy_Registrar(); + $writer = new Spy_Writer(); + $activator = new Spy_Activator(); + + $checker = new class() implements Plugin_Checker_Interface { + /** + * Basenames this checker reports as active. + * + * Writable, because the standalone really does go away between the two requests below and a + * checker that never noticed would resolve the same conflict for ever. + * + * @var string[] + */ + public $active = []; + + /** + * @var string[] + */ + public $basenames = []; + + /** + * @param string $basename Plugin basename. + * + * @return bool + */ + public function is_active( string $basename ): bool { + $this->basenames[] = $basename; + + return in_array( $basename, $this->active, true ); + } + }; + + $deactivator = new class() implements Plugin_Deactivator_Interface { + /** + * @var string[] + */ + public $basenames = []; + + /** + * @param string $basename Plugin basename. + * + * @return void + */ + public function deactivate( string $basename ): void { + $this->basenames[] = $basename; + } + }; + + $checker->active = [ self::STANDALONE ]; + + // Really active, so that the default deactivator would have emptied this option had it been + // the one reached. Nothing else in this test would notice the difference. + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $container = new Test_Container(); + $container->singleton( + Registrar_Interface::class, + static function () use ( $registrar ): Registrar_Interface { + return $registrar; + } + ); + $container->singleton( + Plugin_Checker_Interface::class, + static function () use ( $checker ): Plugin_Checker_Interface { + return $checker; + } + ); + $container->singleton( + Plugin_Deactivator_Interface::class, + static function () use ( $deactivator ): Plugin_Deactivator_Interface { + return $deactivator; + } + ); + $container->singleton( + Writer_Interface::class, + static function () use ( $writer ): Writer_Interface { + return $writer; + } + ); + $container->singleton( + Activator_Interface::class, + static function () use ( $activator ): Activator_Interface { + return $activator; + } + ); + + $this->register( + [ + 'standalone_plugin_basename' => self::STANDALONE, + 'conflict_policy' => Conflict_Policy::DEACTIVATE, + 'activation_callback' => static fn() => null, + ] + ); + + $this->boot( $container ); + + // The conflict step, which ends where production ends it. + $this->run_halted_request(); + + $this->assertSame( [ self::STANDALONE ], $deactivator->basenames, 'The host deactivator is the one asked to turn it off.' ); + $this->assertSame( [ self::SLUG ], $writer->merge_notices, 'The host notice writer is told what happened.' ); + + // What the host's own deactivator did, as far as its own checker is concerned. Nothing is + // re-registered between the two — this is the next page view, not a second bootstrap. + $checker->active = []; + + $this->run_request(); + + $this->assertSame( [ self::SLUG ], array_keys( $registrar->sub_plugins ), 'The host registrar holds the registration.' ); + $this->assertSame( + [ self::STANDALONE ], + array_values( array_unique( $checker->basenames ) ), + 'The host checker answers whether the standalone is active, and is asked about nothing else.' + ); + $this->assertSame( [ self::SLUG ], $activator->slugs, 'The host activator runs the one-time setup.' ); + $this->assertSame( 1, $this->bundled_plugin_loads() ); + + $this->assertContains( self::STANDALONE, $this->active_plugins(), 'The default deactivator must not have run too.' ); + $this->assertSame( [], $this->queued_notices(), 'The default writer must not have been resolved alongside it.' ); + $this->assertSame( [], $this->activation_record(), 'The default activator must not have recorded anything.' ); + } + + /** + * The same guarantee for the two the conflict step resolves itself, and the two rules a host has + * to follow to get it. + * + * An interface id goes in *before* boot: nothing can build an interface unprompted, so `has()` + * answers it only where a binding exists and `Provider` leaves the host's object alone. A concrete + * class id goes in *after* boot, because di52 answers `has()` true for any class that exists + * whether or not anything was bound — the provider cannot tell the host's binding apart from the + * container's willingness to autowire, and binds over it. After boot is not too late, because + * `Boot\Scheduler` wires closures that resolve their collaborator when the hook fires rather than + * at boot; the lazy wiring is what leaves this window open at all. + * + * A host owns what a conflict means — but not who may have one resolved, which is why the gate is + * asked first and separately, and both halves of it are asserted here to have been asked at all. + */ + public function test_a_host_binding_replaces_the_gatekeeper_and_the_resolver(): void { + $gatekeeper = new Spy_Gatekeeper( true ); + $resolver = new Spy_Resolver(); + + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $container = new Test_Container(); + $container->singleton( + Resolver_Interface::class, + static function () use ( $resolver ): Resolver_Interface { + return $resolver; + } + ); + + $this->register( + [ + 'standalone_plugin_basename' => self::STANDALONE, + 'conflict_policy' => Conflict_Policy::DEACTIVATE, + ] + ); + + $this->boot( $container ); + + // After boot, and the only place this one can go: bound first it would be replaced by the + // provider's own default, and the test would assert against a spy nothing ever reached. + $container->singleton( + Gatekeeper::class, + static function () use ( $gatekeeper ): Gatekeeper { + return $gatekeeper; + } + ); + + $this->run_request(); + + $this->assertSame( + 1, + $gatekeeper->request_may_resolve_calls, + 'The conflict step has to ask the gate before it resolves anything.' + ); + $this->assertSame( + 1, + $gatekeeper->user_may_resolve_calls, + 'And the capability half of it, once a conflict is known to exist.' + ); + $this->assertSame( 1, $resolver->resolve_calls ); + $this->assertContains( + self::STANDALONE, + $this->active_plugins(), + 'A host resolver that does nothing means nothing is deactivated.' + ); + $this->assertSame( [], $this->queued_notices() ); + $this->assertSame( 1, $this->bundled_plugin_loads(), 'The load pass still runs after it.' ); + } +}