diff --git a/.distignore b/.distignore index a3ddad1..7d4dced 100644 --- a/.distignore +++ b/.distignore @@ -29,6 +29,7 @@ /infection.json.dist /package.json /package-lock.json +/phpstan-bootstrap.php /phpstan.neon.dist /phpunit.xml.dist /rector.php diff --git a/.gitattributes b/.gitattributes index 2730648..79407bb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -40,6 +40,7 @@ /infection.json.dist export-ignore /package-lock.json export-ignore /package.json export-ignore +/phpstan-bootstrap.php export-ignore /phpstan.neon.dist export-ignore /phpunit.xml.dist export-ignore /rector.php export-ignore diff --git a/phpstan-bootstrap.php b/phpstan-bootstrap.php new file mode 100644 index 0000000..ff3f210 --- /dev/null +++ b/phpstan-bootstrap.php @@ -0,0 +1,17 @@ +run(); + new Bootstrapper()->init(); } ); - -/** - * Get the plugin instance. - * - * Builds and caches the Plugin object on first call, so no work happens - * when this file is merely included. - * - * @since 0.1.0 - * - * @return Plugin Plugin instance. - */ -function plugin_slug(): Plugin { - static $plugin = null; - - if ( ! $plugin instanceof Plugin ) { - $plugin = new Plugin( __FILE__ ); - } - - return $plugin; -} diff --git a/src/Bootstrapper.php b/src/Bootstrapper.php new file mode 100644 index 0000000..6720a38 --- /dev/null +++ b/src/Bootstrapper.php @@ -0,0 +1,36 @@ +register(); + } +} diff --git a/src/Plugin.php b/src/SettingsPage.php similarity index 93% rename from src/Plugin.php rename to src/SettingsPage.php index d6bdf08..218f522 100644 --- a/src/Plugin.php +++ b/src/SettingsPage.php @@ -1,6 +1,6 @@ dir = plugin_dir_path( $file ); - $this->url = plugin_dir_url( $file ); + public function __construct() { + $this->dir = plugin_dir_path( PLUGIN_SLUG_FILE ); + $this->url = plugin_dir_url( PLUGIN_SLUG_FILE ); } /** - * Launch the initialization process. + * Register the feature's hooks. * - * @since 0.1.0 + * @since 0.2.0 */ - public function run(): void { + public function register(): void { add_action( 'admin_menu', $this->register_settings_page( ... ) ); add_action( 'admin_init', $this->register_settings( ... ) ); add_action( 'admin_enqueue_scripts', $this->enqueue_admin_assets( ... ) ); @@ -141,7 +138,7 @@ private function render_settings_page(): void { * The field passes a label_for argument matching the id of the input in * its view, so the rendered field title labels that control. * - * @since 0.1.0 + * @since 0.2.0 */ public function register_settings(): void { register_setting( diff --git a/tests/Unit/BootstrapperTest.php b/tests/Unit/BootstrapperTest.php new file mode 100644 index 0000000..0708283 --- /dev/null +++ b/tests/Unit/BootstrapperTest.php @@ -0,0 +1,55 @@ +justReturn( '/plugin/' ); + Functions\when( 'plugin_dir_url' )->justReturn( 'https://example.test/' ); + } + + /** + * The init() method registers the settings page feature with WordPress. + */ + public function test_init_registers_the_settings_page(): void { + // SettingsPage::register() adds exactly these three hooks. + Functions\expect( 'add_action' )->once()->with( 'admin_menu', Mockery::type( Closure::class ) ); + Functions\expect( 'add_action' )->once()->with( 'admin_init', Mockery::type( Closure::class ) ); + Functions\expect( 'add_action' )->once()->with( 'admin_enqueue_scripts', Mockery::type( Closure::class ) ); + + new Testee()->init(); + } +} diff --git a/tests/Unit/PluginTest.php b/tests/Unit/SettingsPageTest.php similarity index 75% rename from tests/Unit/PluginTest.php rename to tests/Unit/SettingsPageTest.php index 1052a03..6daddae 100644 --- a/tests/Unit/PluginTest.php +++ b/tests/Unit/SettingsPageTest.php @@ -1,6 +1,6 @@ fixtures = dirname( __DIR__ ) . '/fixtures/'; + if ( ! defined( 'PLUGIN_SLUG_FILE' ) ) { + define( 'PLUGIN_SLUG_FILE', '/plugin/plugin-slug.php' ); + } + // Titles are passed through unchanged so exact-argument assertions read clearly. Functions\when( '__' )->returnArg(); } /** - * Build a Plugin whose directory and URL are stubbed. + * Build a SettingsPage whose directory and URL are stubbed. * * @param string|null $dir Directory the plugin should resolve to. Defaults * to the fixtures directory. - * @return Testee Plugin under test. + * @return Testee Settings page under test. */ - private function make_plugin( ?string $dir = null ): Testee { + private function make_page( ?string $dir = null ): Testee { Functions\when( 'plugin_dir_path' )->justReturn( $dir ?? $this->fixtures ); Functions\when( 'plugin_dir_url' )->justReturn( 'https://example.test/' ); - return new Testee( '/plugin/plugin-slug.php' ); + return new Testee(); } /** * Register the settings page so the captured hook suffix is available. * - * @param Testee $plugin Plugin under test. + * @param Testee $page Settings page under test. * @param string|bool $hook_suffix Value add_options_page() should return. */ - private function register_page_returning( Testee $plugin, string|bool $hook_suffix ): void { + private function register_page_returning( Testee $page, string|bool $hook_suffix ): void { Functions\expect( 'add_options_page' )->once()->andReturn( $hook_suffix ); - $plugin->register_settings_page(); + $page->register_settings_page(); } /** @@ -92,61 +96,61 @@ private function capture( Closure $callback, array ...$args ): string { /** * Build a Mockery matcher asserting a first-class callable wraps a method. * - * @param Testee $plugin Plugin the callable should be bound to. + * @param Testee $page Settings page the callable should be bound to. * @param string $method Method name the callable should wrap. * * @return object Mockery matcher. */ - private function bound_to( Testee $plugin, string $method ): object { + private function bound_to( Testee $page, string $method ): object { return Mockery::on( - fn( $candidate ): bool => $this->is_bound_to( $candidate, $plugin, $method ) + fn( $candidate ): bool => $this->is_bound_to( $candidate, $page, $method ) ); } /** - * The run() method wires register_settings_page() onto admin_menu. + * The register() method wires register_settings_page() onto admin_menu. */ - public function test_run_registers_settings_page_on_admin_menu(): void { - $plugin = $this->make_plugin(); + public function test_register_wires_settings_page_onto_admin_menu(): void { + $page = $this->make_page(); - Functions\expect( 'add_action' )->once()->with( 'admin_menu', $this->bound_to( $plugin, 'register_settings_page' ) ); + Functions\expect( 'add_action' )->once()->with( 'admin_menu', $this->bound_to( $page, 'register_settings_page' ) ); Functions\expect( 'add_action' )->with( 'admin_init', Mockery::any() ); Functions\expect( 'add_action' )->with( 'admin_enqueue_scripts', Mockery::any() ); - $plugin->run(); + $page->register(); } /** - * The run() method wires register_settings() onto admin_init. + * The register() method wires register_settings() onto admin_init. */ - public function test_run_registers_settings_on_admin_init(): void { - $plugin = $this->make_plugin(); + public function test_register_wires_settings_onto_admin_init(): void { + $page = $this->make_page(); Functions\expect( 'add_action' )->with( 'admin_menu', Mockery::any() ); - Functions\expect( 'add_action' )->once()->with( 'admin_init', $this->bound_to( $plugin, 'register_settings' ) ); + Functions\expect( 'add_action' )->once()->with( 'admin_init', $this->bound_to( $page, 'register_settings' ) ); Functions\expect( 'add_action' )->with( 'admin_enqueue_scripts', Mockery::any() ); - $plugin->run(); + $page->register(); } /** - * The run() method wires enqueue_admin_assets() onto admin_enqueue_scripts. + * The register() method wires enqueue_admin_assets() onto admin_enqueue_scripts. */ - public function test_run_enqueues_assets_on_admin_enqueue_scripts(): void { - $plugin = $this->make_plugin(); + public function test_register_wires_enqueue_onto_admin_enqueue_scripts(): void { + $page = $this->make_page(); Functions\expect( 'add_action' )->with( 'admin_menu', Mockery::any() ); Functions\expect( 'add_action' )->with( 'admin_init', Mockery::any() ); - Functions\expect( 'add_action' )->once()->with( 'admin_enqueue_scripts', $this->bound_to( $plugin, 'enqueue_admin_assets' ) ); + Functions\expect( 'add_action' )->once()->with( 'admin_enqueue_scripts', $this->bound_to( $page, 'enqueue_admin_assets' ) ); - $plugin->run(); + $page->register(); } /** * The settings page is added under the Settings menu with exact arguments. */ public function test_register_settings_page_adds_options_page_with_exact_arguments(): void { - $plugin = $this->make_plugin(); + $page = $this->make_page(); Functions\expect( 'add_options_page' ) ->once() @@ -159,14 +163,14 @@ public function test_register_settings_page_adds_options_page_with_exact_argumen ) ->andReturn( 'settings_page_plugin-slug' ); - $plugin->register_settings_page(); + $page->register_settings_page(); } /** * The settings page callback renders the page view. */ public function test_settings_page_callback_renders_the_view(): void { - $plugin = $this->make_plugin(); + $page = $this->make_page(); $captured = null; Functions\expect( 'add_options_page' ) @@ -186,7 +190,7 @@ static function ( $candidate ) use ( &$captured ): bool { ) ->andReturn( 'settings_page_plugin-slug' ); - $plugin->register_settings_page(); + $page->register_settings_page(); static::assertInstanceOf( Closure::class, $captured ); static::assertSame( 'ADMIN_PAGE_FIXTURE', $this->capture( $captured ) ); @@ -196,7 +200,7 @@ static function ( $candidate ) use ( &$captured ): bool { * The setting is registered with exact arguments. */ public function test_register_settings_registers_setting_with_exact_arguments(): void { - $plugin = $this->make_plugin(); + $page = $this->make_page(); Functions\expect( 'register_setting' ) ->once() @@ -207,21 +211,21 @@ public function test_register_settings_registers_setting_with_exact_arguments(): fn( $args ): bool => is_array( $args ) && 'array' === $args['type'] && [] === $args['default'] - && $this->is_bound_to( $args['sanitize_callback'], $plugin, 'sanitize_settings' ) + && $this->is_bound_to( $args['sanitize_callback'], $page, 'sanitize_settings' ) ) ); Functions\expect( 'add_settings_section' )->andReturnNull(); Functions\expect( 'add_settings_field' )->andReturnNull(); - $plugin->register_settings(); + $page->register_settings(); } /** * The section is registered with exact arguments. */ public function test_register_settings_adds_section_with_exact_arguments(): void { - $plugin = $this->make_plugin(); + $page = $this->make_page(); Functions\expect( 'register_setting' )->andReturnNull(); Functions\expect( 'add_settings_section' ) @@ -234,14 +238,14 @@ public function test_register_settings_adds_section_with_exact_arguments(): void ); Functions\expect( 'add_settings_field' )->andReturnNull(); - $plugin->register_settings(); + $page->register_settings(); } /** * The section callback renders the section view. */ public function test_section_callback_renders_the_view(): void { - $plugin = $this->make_plugin(); + $page = $this->make_page(); $captured = null; Functions\expect( 'register_setting' )->andReturnNull(); @@ -261,7 +265,7 @@ static function ( $candidate ) use ( &$captured ): bool { ); Functions\expect( 'add_settings_field' )->andReturnNull(); - $plugin->register_settings(); + $page->register_settings(); static::assertInstanceOf( Closure::class, $captured ); static::assertSame( 'SECTION_FIXTURE', $this->capture( $captured ) ); @@ -271,7 +275,7 @@ static function ( $candidate ) use ( &$captured ): bool { * The field is registered with exact arguments. */ public function test_register_settings_adds_field_with_exact_arguments(): void { - $plugin = $this->make_plugin(); + $page = $this->make_page(); Functions\expect( 'register_setting' )->andReturnNull(); Functions\expect( 'add_settings_section' )->andReturnNull(); @@ -289,14 +293,14 @@ public function test_register_settings_adds_field_with_exact_arguments(): void { ] ); - $plugin->register_settings(); + $page->register_settings(); } /** * The field callback renders the field view with the args passed through. */ public function test_field_callback_renders_the_view_with_args(): void { - $plugin = $this->make_plugin(); + $page = $this->make_page(); $captured = null; Functions\expect( 'register_setting' )->andReturnNull(); @@ -318,7 +322,7 @@ static function ( $candidate ) use ( &$captured ): bool { Mockery::any() ); - $plugin->register_settings(); + $page->register_settings(); static::assertInstanceOf( Closure::class, $captured ); @@ -337,7 +341,7 @@ static function ( $candidate ) use ( &$captured ): bool { * Non-array input sanitizes to an empty array. */ public function test_sanitize_settings_returns_empty_array_for_non_array(): void { - static::assertSame( [], $this->make_plugin()->sanitize_settings( 'not-an-array' ) ); + static::assertSame( [], $this->make_page()->sanitize_settings( 'not-an-array' ) ); } /** @@ -356,7 +360,7 @@ public function test_sanitize_settings_sanitizes_each_scalar_field(): void { 'field1' => 'clean:hello', 'field2' => 'clean:42', ], - $this->make_plugin()->sanitize_settings( + $this->make_page()->sanitize_settings( [ 'field1' => 'hello', 'field2' => 42, @@ -373,7 +377,7 @@ public function test_sanitize_settings_replaces_non_scalar_fields(): void { static::assertSame( [ 'field1' => '' ], - $this->make_plugin()->sanitize_settings( [ 'field1' => [ 'nested' ] ] ) + $this->make_page()->sanitize_settings( [ 'field1' => [ 'nested' ] ] ) ); } @@ -381,47 +385,47 @@ public function test_sanitize_settings_replaces_non_scalar_fields(): void { * Assets are not enqueued on admin pages other than the plugin's own. */ public function test_enqueue_skips_other_pages(): void { - $plugin = $this->make_plugin(); - $this->register_page_returning( $plugin, 'settings_page_plugin-slug' ); + $page = $this->make_page(); + $this->register_page_returning( $page, 'settings_page_plugin-slug' ); Functions\expect( 'wp_enqueue_script' )->never(); Functions\expect( 'wp_enqueue_style' )->never(); - $plugin->enqueue_admin_assets( 'index.php' ); + $page->enqueue_admin_assets( 'index.php' ); } /** * Nothing is enqueued when the settings page failed to register. */ public function test_enqueue_skips_when_page_registration_failed(): void { - $plugin = $this->make_plugin(); - $this->register_page_returning( $plugin, false ); + $page = $this->make_page(); + $this->register_page_returning( $page, false ); Functions\expect( 'wp_enqueue_script' )->never(); Functions\expect( 'wp_enqueue_style' )->never(); - $plugin->enqueue_admin_assets( 'settings_page_plugin-slug' ); + $page->enqueue_admin_assets( 'settings_page_plugin-slug' ); } /** * Nothing is enqueued before the assets have been built. */ public function test_enqueue_skips_when_build_is_absent(): void { - $plugin = $this->make_plugin( '/no/such/directory/' ); - $this->register_page_returning( $plugin, 'settings_page_plugin-slug' ); + $page = $this->make_page( '/no/such/directory/' ); + $this->register_page_returning( $page, 'settings_page_plugin-slug' ); Functions\expect( 'wp_enqueue_script' )->never(); Functions\expect( 'wp_enqueue_style' )->never(); - $plugin->enqueue_admin_assets( 'settings_page_plugin-slug' ); + $page->enqueue_admin_assets( 'settings_page_plugin-slug' ); } /** * The built script and style are enqueued on the plugin's own page. */ public function test_enqueue_enqueues_the_built_script_and_style(): void { - $plugin = $this->make_plugin(); - $this->register_page_returning( $plugin, 'settings_page_plugin-slug' ); + $page = $this->make_page(); + $this->register_page_returning( $page, 'settings_page_plugin-slug' ); Functions\expect( 'wp_enqueue_script' ) ->once() @@ -450,23 +454,23 @@ public function test_enqueue_enqueues_the_built_script_and_style(): void { ->once() ->with( 'plugin-slug-admin-settings', 'rtl', 'replace' ); - $plugin->enqueue_admin_assets( 'settings_page_plugin-slug' ); + $page->enqueue_admin_assets( 'settings_page_plugin-slug' ); } /** * Whether a candidate is a first-class callable wrapping a bound method. * * @param mixed $candidate Value to inspect. - * @param Testee $plugin Plugin the callable should be bound to. + * @param Testee $page Settings page the callable should be bound to. * @param string $method Method name the callable should wrap. */ - private function is_bound_to( mixed $candidate, Testee $plugin, string $method ): bool { + private function is_bound_to( mixed $candidate, Testee $page, string $method ): bool { if ( ! $candidate instanceof Closure ) { return false; } $reflection = new ReflectionFunction( $candidate ); - return $method === $reflection->getName() && $plugin === $reflection->getClosureThis(); + return $method === $reflection->getName() && $page === $reflection->getClosureThis(); } }