diff --git a/README.md b/README.md index f8cbcaa..9a0d5e4 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,44 @@ When a sub-plugin's standalone counterpart is still active: | `Conflict_Policy::DEFER` | Leave the standalone active; the load guard stands the bundled copy down. | | `Conflict_Policy::NOTICE_ONLY` | Leave it active and ask the user to deactivate it. | +### Sub-plugin configuration + +| Key | Type | Required | Meaning | +|---|---|:--:|---| +| `slug` | `string` | ✔ | Unique id — registry key, notice id, activation-tracking key. | +| `bundled_plugin_file` | `string` | ✔ | Absolute path to the **bundled** plugin's main file. This is what gets `require_once`d. | +| `plugin_loaded_constant` | `string` | ✔ | A constant the plugin defines when it loads. Both copies must define the *same* name, **at file scope**. `defined()` ⇒ skip, which is what prevents re-declaration fatals. **Load guard only.** | +| `standalone_plugin_basename` | `string` | | The standalone's `dir/file.php` basename. Used for `is_plugin_active()` and `deactivate_plugins()`. Omit when there is no standalone. **Detection only.** | +| `enabled` | `bool\|callable` | | `true` by default. A `callable( Sub_Plugin ): bool` is re-evaluated on every call, not cached. | +| `conflict_policy` | `string\|callable` | | `Conflict_Policy::DEACTIVATE` by default. A `callable( Sub_Plugin ): string` decides at runtime. The `conflict_policy` filter below overrides both. | +| `conflict_notice_message` | `string\|callable` | | Shown on auto-deactivation and on a re-activation attempt. Empty by default. Use a callable to defer `__()` past `init`. | +| `dependency_notice_message` | `string\|callable` | | Shown when `dependency_check` fails. Defaults to a generic, untranslated sentence naming the raw slug. | +| `activation_callback` | `callable( Sub_Plugin )` | | Runs **exactly once, ever**, per slug. | +| `dependency_check` | `callable( Sub_Plugin ): bool` | | Skips the load and queues a notice when it returns false. | + +The load guard and the standalone basename are deliberately **two separate keys**. No constant does +double duty as both a guard and a path resolver. + +A value that is a `string` is always used as a value, never called — even when a function of that +name exists. `dependency_check` and `activation_callback` are the two keys that are only ever +callables, and a value under either that cannot be called is rejected when the sub-plugin is +registered rather than ignored at the point of use. + +The guard constant must be defined at **file scope**. A standalone that defines it from a bootstrap +hooked at `plugins_loaded` or later has not defined it yet at the moment the guard is read, and the +bundled copy would load on top of it. + +The guard also cannot help on the request that *activates* the standalone: WordPress includes it +after the bundled copy has already loaded, so that re-declaration is a real fatal. WordPress catches +it in its activation sandbox, and this library rewrites the resulting error screen into an +explanation. + +### Filters + +| Filter | Arguments | Purpose | +|---|---|---| +| `{prefix}/plugin_absorber/conflict_policy` | `string $policy`, `Sub_Plugin $sub_plugin` | Final say over the conflict policy, after the config value and any callable. | + ## License This program is free software; you can redistribute it and/or modify it under the terms of the diff --git a/docs/superpowers/plans/2026-07-31-plugin-absorber.md b/docs/superpowers/plans/2026-07-31-plugin-absorber.md index 41620be..b8ec47d 100644 --- a/docs/superpowers/plans/2026-07-31-plugin-absorber.md +++ b/docs/superpowers/plans/2026-07-31-plugin-absorber.md @@ -1540,6 +1540,39 @@ git checkout 06-conflict-policy && git checkout -b 07-sub-plugin `wp-admin/includes/plugin.php` is required in `setUp()` because uopz cannot stub a function that does not yet exist. +> **Deviations, deliberate (added 2026-08-03, from the PR 7 review):** +> +> 1. **`is_callable()` is no longer the type discriminator.** It returns true for *any* string +> naming an existing function, and `conflict_policy` is explicitly designed to be readable from +> an option. A stored value of `date` or `flush` would have been invoked rather than used — +> a `TypeError` at `plugins_loaded` on PHP 8, a silent `''` on 7.4. Strings and bools are now +> always values; only real callables are called. +> 2. **The constructor type-checks.** Required keys must be non-empty *strings*: an array survived +> the old `empty()` check and then cast to `"Array"`, which every misconfigured sub-plugin would +> have collided on as its registry key, activation key, and notice id. `dependency_check` and +> `activation_callback` must be callable when present — `is_callable()` at read time conflated +> "not configured" with "configured but uncallable", so a `dependency_check` pointing at a +> private method reported dependencies *met* and let the load proceed into the fatal it exists +> to prevent. +> 3. **`is_standalone_plugin_active()` no longer ORs in `is_plugin_active_for_network()`.** Verified +> against core: `is_plugin_active()` already does, so the OR was dead code costing a second +> `get_site_option()` per sub-plugin per request. The test that pinned it described a state +> WordPress cannot produce. +> 4. **`load_plugin_functions()` guards on `is_plugin_active_for_network`,** not `is_plugin_active`. +> Both live in the same file, but a third party defining an `is_plugin_active` shim — a known WP +> idiom — would short-circuit the require and leave the network predicate undefined. +> 5. **`get_conflict_notice_message()` takes a `$default`.** Task 14 has no fallback of its own, so +> an unconfigured host would have been shown WordPress's raw fatal-error screen. +> 6. **The filter result is `is_scalar()`-guarded.** A filter returning `WP_Error` would otherwise +> be a fatal on cast; `''` is simply not a valid policy and routes to the conservative branch. + +> **CORRECTION (2026-08-03, hit while implementing):** the fixture helper below is named `make()`, +> which collides with `Codeception\Test\Unit::make()` — a public method on `WPTestCase`'s ancestor. +> Declaring it `private` is a fatal at class-compile time: *"Access level to +> SubPluginTest::make() must be public"*. The suite does not fail, it fails to start. Renamed to +> `make_sub_plugin()` throughout. Any later task adding a fixture helper must avoid Codeception's +> own `Unit` API — `make`, `makeEmpty`, `construct`, and `constructEmpty` are all taken. + ```php get_conflict_notice_message(); + // The default matters: without one, a host that never configured a message gets + // WordPress's raw "triggered a fatal error" screen -- the exact outcome this rewrite + // exists to prevent -- and the rewrite would silently do nothing. + $message = $sub_plugin->get_conflict_notice_message( + sprintf( + '%s is bundled with this plugin and loads automatically. The standalone copy cannot be activated alongside it.', + $sub_plugin->get_slug() + ) + ); if ( $message === '' ) { return $markup; diff --git a/src/Sub_Plugin.php b/src/Sub_Plugin.php new file mode 100644 index 0000000..e4a8234 --- /dev/null +++ b/src/Sub_Plugin.php @@ -0,0 +1,333 @@ + + */ + private $config; + + /** + * @since 1.0.0 + * + * @param array $config Sub-plugin configuration. + * + * @throws Config_Exception When a required key is missing, empty, or not a string, or when a + * callable-only key holds something that cannot be called. + */ + public function __construct( array $config ) { + foreach ( self::REQUIRED_KEYS as $required ) { + if ( ! isset( $config[ $required ] ) ) { + throw new Config_Exception( "Sub-plugin config is missing required key: {$required}" ); + } + + // Not just a truthiness check. An array survives one of those and then casts to the + // string "Array", which every sub-plugin with the same mistake would share as its + // registry key, its activation-tracking key, and its notice id. + if ( ! is_string( $config[ $required ] ) || $config[ $required ] === '' ) { + throw new Config_Exception( + "Sub-plugin config key must be a non-empty string: {$required}" + ); + } + } + + // Rejected here rather than ignored at read time, where "not configured" and "configured + // but uncallable" would collapse into the same answer. A dependency_check that is a + // private method or a typo'd function name would otherwise report dependencies met and + // let the load proceed into the fatal it exists to prevent. + foreach ( self::CALLABLE_KEYS as $key ) { + if ( isset( $config[ $key ] ) && ! is_callable( $config[ $key ] ) ) { + throw new Config_Exception( "Sub-plugin config key must be callable: {$key}" ); + } + } + + $this->config = $config; + } + + /** + * @since 1.0.0 + * + * @return string + */ + public function get_slug(): string { + return (string) $this->config['slug']; + } + + /** + * Absolute path to the bundled plugin's main file — the file we require_once. + * + * @since 1.0.0 + * + * @return string + */ + public function get_bundled_plugin_file(): string { + return (string) $this->config['bundled_plugin_file']; + } + + /** + * Constant both the bundled copy and the standalone define when they load. + * + * @since 1.0.0 + * + * @return string + */ + public function get_plugin_loaded_constant(): string { + return (string) $this->config['plugin_loaded_constant']; + } + + /** + * Resolve the policy from a string or callable, then let the filter override it. + * + * The result is not checked against the known policies here: a filter may legitimately + * return anything, and rejecting it at this boundary would hide the override rather than + * report it. Callers that dispatch on the value check it with Conflict_Policy::is_valid(). + * + * @since 1.0.0 + * + * @throws Config_Exception When no hook prefix has been set. + * + * @return string + */ + public function get_conflict_policy(): string { + $policy = $this->config['conflict_policy'] ?? Conflict_Policy::DEACTIVATE; + + $policy = apply_filters( + Config::get_hook_prefix() . '/plugin_absorber/conflict_policy', + $this->resolve_callable( $policy ), + $this + ); + + // A filter returning an object or an array is a mistake, and casting one would be a + // fatal at plugins_loaded. An empty string is not a valid policy, so it routes to the + // conservative branch instead. + return is_scalar( $policy ) ? (string) $policy : ''; + } + + /** + * @since 1.0.0 + * + * @return bool + */ + public function is_enabled(): bool { + return (bool) $this->resolve_callable( $this->config['enabled'] ?? true ); + } + + /** + * True when the plugin's code is already present, from either copy. The fatal guard. + * + * Only sound when the constant is defined at file scope. A standalone that defines it from a + * bootstrap hooked at plugins_loaded or later has not defined it yet when this is asked. + * + * @since 1.0.0 + * + * @return bool + */ + public function is_already_loaded(): bool { + return defined( $this->get_plugin_loaded_constant() ); + } + + /** + * @since 1.0.0 + * + * @return bool + */ + public function has_standalone_plugin(): bool { + return ! empty( $this->config['standalone_plugin_basename'] ); + } + + /** + * @since 1.0.0 + * + * @return string + */ + public function get_standalone_plugin_basename(): string { + return (string) ( $this->config['standalone_plugin_basename'] ?? '' ); + } + + /** + * Whether the standalone is active, site-wide or network-wide. + * + * WordPress's own is_plugin_active() already ORs in the network check, so asking it again + * here would only buy a second get_site_option() per sub-plugin per request. + * + * @since 1.0.0 + * + * @return bool + */ + public function is_standalone_plugin_active(): bool { + if ( ! $this->has_standalone_plugin() ) { + return false; + } + + $this->load_plugin_functions(); + + return is_plugin_active( $this->get_standalone_plugin_basename() ); + } + + /** + * Whether the standalone is network-activated. + * + * Deactivating it requires passing $network_wide to deactivate_plugins(); without that the + * call silently no-ops and the resolver redirects forever. + * + * @since 1.0.0 + * + * @return bool + */ + public function is_standalone_plugin_network_active(): bool { + if ( ! $this->has_standalone_plugin() ) { + return false; + } + + $this->load_plugin_functions(); + + return is_plugin_active_for_network( $this->get_standalone_plugin_basename() ); + } + + /** + * @since 1.0.0 + * + * @return bool + */ + public function are_dependencies_met(): bool { + $check = $this->config['dependency_check'] ?? null; + + if ( $check === null ) { + return true; + } + + return (bool) $check( $this ); + } + + /** + * Shown when the standalone is auto-deactivated, and when the user tries to re-activate it. + * + * The fallback is a parameter because the two contexts want different wording, and because + * a caller with no fallback of its own would otherwise render nothing at all. + * + * @since 1.0.0 + * + * @param string $default Used when nothing is configured. + * + * @return string + */ + public function get_conflict_notice_message( string $default = '' ): string { + $message = $this->resolve_message( $this->config['conflict_notice_message'] ?? '' ); + + return $message !== '' ? $message : $default; + } + + /** + * Shown when a dependency_check fails. Falls back to a generic, untranslated sentence — + * pass a callable returning __() to localise it. + * + * @since 1.0.0 + * + * @return string + */ + public function get_dependency_notice_message(): string { + $message = $this->resolve_message( $this->config['dependency_notice_message'] ?? '' ); + + if ( $message !== '' ) { + return $message; + } + + return sprintf( + '%s could not be loaded because its requirements are not met.', + $this->get_slug() + ); + } + + /** + * @since 1.0.0 + * + * @return callable|null + */ + public function get_activation_callback(): ?callable { + $callback = $this->config['activation_callback'] ?? null; + + return is_callable( $callback ) ? $callback : null; + } + + /** + * Resolve a string-or-callable message. + * + * @since 1.0.0 + * + * @param mixed $message Configured message. + * + * @return string + */ + private function resolve_message( $message ): string { + $message = $this->resolve_callable( $message ); + + return is_scalar( $message ) ? (string) $message : ''; + } + + /** + * Call a configured value if it is meant to be called, and return it as-is otherwise. + * + * Strings are returned untouched even when a function of that name exists. is_callable() + * alone would treat a policy or a message read from an option as a function to invoke — + * "date" and "flush" are both valid function names and plausible option values. + * + * @since 1.0.0 + * + * @param mixed $value Configured value. + * + * @return mixed + */ + private function resolve_callable( $value ) { + if ( is_string( $value ) || is_bool( $value ) || ! is_callable( $value ) ) { + return $value; + } + + return $value( $this ); + } + + /** + * WordPress only loads these in the admin, and we run at plugins_loaded on every request. + * + * Guarded on is_plugin_active_for_network() rather than is_plugin_active(), because the + * latter is a common third-party shim: something else defining it would short-circuit this + * and leave the network predicate calling a function that was never loaded. + * + * @since 1.0.0 + * + * @return void + */ + private function load_plugin_functions(): void { + if ( ! function_exists( 'is_plugin_active_for_network' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + } +} diff --git a/tests/unit/SubPluginTest.php b/tests/unit/SubPluginTest.php new file mode 100644 index 0000000..15d6aeb --- /dev/null +++ b/tests/unit/SubPluginTest.php @@ -0,0 +1,504 @@ + $overrides Config overrides. + */ + private function make_sub_plugin( array $overrides = [] ): Sub_Plugin { + return new Sub_Plugin( + array_merge( + [ + 'slug' => 'give-recurring', + 'bundled_plugin_file' => '/tmp/give-recurring/give-recurring.php', + 'plugin_loaded_constant' => 'GIVE_RECURRING_VERSION_TEST', + ], + $overrides + ) + ); + } + + /** + * @dataProvider required_keys + * + * @param string $missing_key Key to omit. + */ + public function test_it_requires_every_required_key( string $missing_key ): void { + $config = [ + 'slug' => 'give-recurring', + 'bundled_plugin_file' => '/tmp/x.php', + 'plugin_loaded_constant' => 'X_VERSION', + ]; + unset( $config[ $missing_key ] ); + + $this->expectException( Config_Exception::class ); + $this->expectExceptionMessage( $missing_key ); + + new Sub_Plugin( $config ); + } + + /** + * @return array + */ + public function required_keys(): array { + return [ + 'slug' => [ 'slug' ], + 'bundled_plugin_file' => [ 'bundled_plugin_file' ], + 'plugin_loaded_constant' => [ 'plugin_loaded_constant' ], + ]; + } + + /** + * @dataProvider unusable_required_values + * + * @param mixed $value Value to put under a required key. + */ + public function test_it_rejects_a_required_key_that_is_not_a_non_empty_string( $value ): void { + $this->expectException( Config_Exception::class ); + $this->expectExceptionMessage( 'slug' ); + + $this->make_sub_plugin( [ 'slug' => $value ] ); + } + + /** + * An array is the one that matters: it survives a truthiness check and then casts to the + * string "Array", which every sub-plugin making the same mistake would collide on. + * + * @return array + */ + public function unusable_required_values(): array { + return [ + 'empty string' => [ '' ], + 'array' => [ [ 'give', 'recurring' ] ], + 'null' => [ null ], + 'integer' => [ 42 ], + 'object' => [ new \stdClass() ], + ]; + } + + /** + * @dataProvider callable_only_keys + * + * @param string $key Key that only ever holds a callable. + */ + public function test_it_rejects_a_callable_only_key_that_cannot_be_called( string $key ): void { + $this->expectException( Config_Exception::class ); + $this->expectExceptionMessage( $key ); + + $this->make_sub_plugin( [ $key => 'absorber_no_such_function' ] ); + } + + /** + * @return array + */ + public function callable_only_keys(): array { + return [ + 'dependency_check' => [ 'dependency_check' ], + 'activation_callback' => [ 'activation_callback' ], + ]; + } + + public function test_it_exposes_the_required_values(): void { + $sub_plugin = $this->make_sub_plugin(); + + $this->assertSame( 'give-recurring', $sub_plugin->get_slug() ); + $this->assertSame( '/tmp/give-recurring/give-recurring.php', $sub_plugin->get_bundled_plugin_file() ); + $this->assertSame( 'GIVE_RECURRING_VERSION_TEST', $sub_plugin->get_plugin_loaded_constant() ); + } + + public function test_it_is_enabled_by_default(): void { + $this->assertTrue( $this->make_sub_plugin()->is_enabled() ); + } + + public function test_it_honours_a_boolean_enabled_flag(): void { + $this->assertFalse( $this->make_sub_plugin( [ 'enabled' => false ] )->is_enabled() ); + } + + public function test_it_resolves_a_callable_enabled_flag_at_call_time(): void { + $switch = false; + $sub_plugin = $this->make_sub_plugin( + [ + 'enabled' => static function () use ( &$switch ) { + return $switch; + }, + ] + ); + + $this->assertFalse( $sub_plugin->is_enabled() ); + + $switch = true; + + $this->assertTrue( $sub_plugin->is_enabled(), 'The callable must be re-evaluated on each call.' ); + } + + public function test_it_reports_not_loaded_when_the_constant_is_undefined(): void { + $this->assertFalse( $this->make_sub_plugin( [ 'plugin_loaded_constant' => 'ABSORBER_NEVER_DEFINED' ] )->is_already_loaded() ); + } + + public function test_it_reports_loaded_once_the_constant_is_defined(): void { + define( 'ABSORBER_TEST_LOADED_CONSTANT', '1.0.0' ); + + $this->assertTrue( $this->make_sub_plugin( [ 'plugin_loaded_constant' => 'ABSORBER_TEST_LOADED_CONSTANT' ] )->is_already_loaded() ); + } + + public function test_it_reports_no_standalone_when_the_basename_is_absent(): void { + $sub_plugin = $this->make_sub_plugin(); + + $this->assertFalse( $sub_plugin->has_standalone_plugin() ); + $this->assertSame( '', $sub_plugin->get_standalone_plugin_basename() ); + $this->assertFalse( $sub_plugin->is_standalone_plugin_active() ); + $this->assertFalse( $sub_plugin->is_standalone_plugin_network_active() ); + } + + public function test_it_never_calls_wordpress_without_a_standalone(): void { + $this->setFunctionReturn( 'is_plugin_active', true ); + $this->setFunctionReturn( 'is_plugin_active_for_network', true ); + + $this->assertFalse( + $this->make_sub_plugin()->is_standalone_plugin_active(), + 'Absent a standalone basename the predicate must short-circuit.' + ); + } + + public function test_it_reports_a_configured_standalone(): void { + $sub_plugin = $this->make_sub_plugin( [ 'standalone_plugin_basename' => 'give-recurring/give-recurring.php' ] ); + + $this->assertTrue( $sub_plugin->has_standalone_plugin() ); + $this->assertSame( 'give-recurring/give-recurring.php', $sub_plugin->get_standalone_plugin_basename() ); + } + + /** + * The basename is what later reaches deactivate_plugins() and the activation-error rewrite, + * so asserting only the return value would let the wrong string be passed unnoticed. + */ + public function test_it_passes_the_standalone_basename_to_wordpress(): void { + $received = []; + + $this->setFunctionReturn( + 'is_plugin_active', + static function ( $basename ) use ( &$received ) { + $received['is_plugin_active'] = $basename; + + return true; + }, + true + ); + $this->setFunctionReturn( + 'is_plugin_active_for_network', + static function ( $basename ) use ( &$received ) { + $received['is_plugin_active_for_network'] = $basename; + + return true; + }, + true + ); + + $sub_plugin = $this->make_sub_plugin( [ 'standalone_plugin_basename' => 'give-recurring/give-recurring.php' ] ); + + $sub_plugin->is_standalone_plugin_active(); + $sub_plugin->is_standalone_plugin_network_active(); + + $this->assertSame( + [ + 'is_plugin_active' => 'give-recurring/give-recurring.php', + 'is_plugin_active_for_network' => 'give-recurring/give-recurring.php', + ], + $received + ); + } + + public function test_it_delegates_the_active_check_to_wordpress(): void { + $this->setFunctionReturn( 'is_plugin_active', true ); + $this->setFunctionReturn( 'is_plugin_active_for_network', false ); + + $sub_plugin = $this->make_sub_plugin( [ 'standalone_plugin_basename' => 'give-recurring/give-recurring.php' ] ); + + $this->assertTrue( $sub_plugin->is_standalone_plugin_active() ); + $this->assertFalse( $sub_plugin->is_standalone_plugin_network_active() ); + } + + /** + * WordPress's own is_plugin_active() ORs in the network check, so a network-active plugin + * reports true from both. Stubbing is_plugin_active false here would describe a state + * WordPress cannot produce. + */ + public function test_it_detects_a_network_active_standalone(): void { + $this->setFunctionReturn( 'is_plugin_active', true ); + $this->setFunctionReturn( 'is_plugin_active_for_network', true ); + + $sub_plugin = $this->make_sub_plugin( [ 'standalone_plugin_basename' => 'give-recurring/give-recurring.php' ] ); + + $this->assertTrue( $sub_plugin->is_standalone_plugin_active() ); + $this->assertTrue( $sub_plugin->is_standalone_plugin_network_active() ); + } + + public function test_it_detects_an_inactive_standalone(): void { + $this->setFunctionReturn( 'is_plugin_active', false ); + $this->setFunctionReturn( 'is_plugin_active_for_network', false ); + + $sub_plugin = $this->make_sub_plugin( [ 'standalone_plugin_basename' => 'give-recurring/give-recurring.php' ] ); + + $this->assertFalse( $sub_plugin->is_standalone_plugin_active() ); + } + + public function test_dependencies_are_met_without_a_check(): void { + $this->assertTrue( $this->make_sub_plugin()->are_dependencies_met() ); + } + + public function test_it_honours_the_dependency_check(): void { + $this->assertFalse( + $this->make_sub_plugin( [ 'dependency_check' => static fn() => false ] )->are_dependencies_met() + ); + $this->assertTrue( + $this->make_sub_plugin( [ 'dependency_check' => static fn() => true ] )->are_dependencies_met() + ); + } + + public function test_the_conflict_policy_defaults_to_deactivate(): void { + $this->assertSame( Conflict_Policy::DEACTIVATE, $this->make_sub_plugin()->get_conflict_policy() ); + } + + public function test_it_resolves_a_string_conflict_policy(): void { + $this->assertSame( + Conflict_Policy::DEFER, + $this->make_sub_plugin( [ 'conflict_policy' => Conflict_Policy::DEFER ] )->get_conflict_policy() + ); + } + + public function test_it_resolves_a_callable_conflict_policy_and_passes_itself(): void { + $received = null; + $sub_plugin = $this->make_sub_plugin( + [ + 'conflict_policy' => static function ( Sub_Plugin $passed ) use ( &$received ) { + $received = $passed; + + return Conflict_Policy::NOTICE_ONLY; + }, + ] + ); + + $this->assertSame( Conflict_Policy::NOTICE_ONLY, $sub_plugin->get_conflict_policy() ); + $this->assertSame( $sub_plugin, $received ); + } + + public function test_the_filter_overrides_the_resolved_policy(): void { + add_filter( + 'give/plugin_absorber/conflict_policy', + static function () { + return Conflict_Policy::DEFER; + } + ); + + $sub_plugin = $this->make_sub_plugin( [ 'conflict_policy' => Conflict_Policy::DEACTIVATE ] ); + + $this->assertSame( + Conflict_Policy::DEFER, + $sub_plugin->get_conflict_policy(), + 'The filter runs after the config value and the callable, and wins.' + ); + } + + public function test_the_filter_receives_the_sub_plugin(): void { + $received = null; + + add_filter( + 'give/plugin_absorber/conflict_policy', + static function ( $policy, $passed ) use ( &$received ) { + $received = $passed; + + return $policy; + }, + 10, + 2 + ); + + $sub_plugin = $this->make_sub_plugin(); + $sub_plugin->get_conflict_policy(); + + $this->assertSame( $sub_plugin, $received ); + } + + public function test_the_conflict_notice_message_defaults_to_empty(): void { + $this->assertSame( '', $this->make_sub_plugin()->get_conflict_notice_message() ); + } + + public function test_it_resolves_conflict_notice_messages_from_strings_and_callables(): void { + $this->assertSame( + 'Bundled now.', + $this->make_sub_plugin( [ 'conflict_notice_message' => 'Bundled now.' ] )->get_conflict_notice_message() + ); + $this->assertSame( + 'Deferred.', + $this->make_sub_plugin( [ 'conflict_notice_message' => static fn() => 'Deferred.' ] )->get_conflict_notice_message() + ); + } + + public function test_the_dependency_notice_message_falls_back_to_a_default(): void { + $this->assertSame( + 'give-recurring could not be loaded because its requirements are not met.', + $this->make_sub_plugin()->get_dependency_notice_message() + ); + } + + public function test_it_resolves_dependency_notice_messages_from_strings_and_callables(): void { + $this->assertSame( + 'Needs WooCommerce.', + $this->make_sub_plugin( [ 'dependency_notice_message' => 'Needs WooCommerce.' ] )->get_dependency_notice_message() + ); + $this->assertSame( + 'Needs Give.', + $this->make_sub_plugin( [ 'dependency_notice_message' => static fn() => 'Needs Give.' ] )->get_dependency_notice_message() + ); + } + + /** + * is_callable() is true for any string naming an existing function, and a policy read from + * an option can easily be one. Invoking date() here would be a fatal on PHP 8. + * + * @dataProvider function_names + * + * @param string $name Name of a real PHP function. + */ + public function test_a_policy_string_is_never_invoked_as_a_function( string $name ): void { + $this->assertSame( + $name, + $this->make_sub_plugin( [ 'conflict_policy' => $name ] )->get_conflict_policy() + ); + } + + /** + * @dataProvider function_names + * + * @param string $name Name of a real PHP function. + */ + public function test_a_message_string_is_never_invoked_as_a_function( string $name ): void { + $this->assertSame( + $name, + $this->make_sub_plugin( [ 'conflict_notice_message' => $name ] )->get_conflict_notice_message() + ); + } + + /** + * @return array + */ + public function function_names(): array { + return [ + 'date' => [ 'date' ], + 'flush' => [ 'flush' ], + 'key' => [ 'key' ], + ]; + } + + public function test_a_non_scalar_filter_return_yields_no_policy(): void { + add_filter( + 'give/plugin_absorber/conflict_policy', + static function () { + return new \WP_Error( 'nope', 'Nope.' ); + } + ); + + $this->assertSame( + '', + $this->make_sub_plugin()->get_conflict_policy(), + 'Casting an object would be a fatal; an empty string is simply not a valid policy.' + ); + } + + public function test_the_conflict_policy_needs_a_hook_prefix(): void { + Config::reset(); + + $this->expectException( Config_Exception::class ); + + $this->make_sub_plugin()->get_conflict_policy(); + } + + public function test_the_enabled_callable_receives_the_sub_plugin(): void { + $received = null; + $sub_plugin = $this->make_sub_plugin( + [ + 'enabled' => static function ( $passed ) use ( &$received ) { + $received = $passed; + + return true; + }, + ] + ); + + $sub_plugin->is_enabled(); + + $this->assertSame( $sub_plugin, $received ); + } + + public function test_the_dependency_check_receives_the_sub_plugin(): void { + $received = null; + $sub_plugin = $this->make_sub_plugin( + [ + 'dependency_check' => static function ( $passed ) use ( &$received ) { + $received = $passed; + + return true; + }, + ] + ); + + $sub_plugin->are_dependencies_met(); + + $this->assertSame( $sub_plugin, $received ); + } + + public function test_the_conflict_notice_message_falls_back_to_the_given_default(): void { + $this->assertSame( + 'Bundled now.', + $this->make_sub_plugin()->get_conflict_notice_message( 'Bundled now.' ) + ); + } + + public function test_a_configured_conflict_notice_message_beats_the_default(): void { + $this->assertSame( + 'Configured.', + $this->make_sub_plugin( [ 'conflict_notice_message' => 'Configured.' ] ) + ->get_conflict_notice_message( 'Default.' ) + ); + } + + public function test_the_activation_callback_is_null_by_default(): void { + $this->assertNull( $this->make_sub_plugin()->get_activation_callback() ); + } + + public function test_it_returns_the_activation_callback(): void { + $callback = static function () {}; + + $this->assertSame( $callback, $this->make_sub_plugin( [ 'activation_callback' => $callback ] )->get_activation_callback() ); + } +}