diff --git a/composer.json b/composer.json index a2657d0..60313d0 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,6 @@ }, "require": { "php": "^8.4", - "brightnucleus/config": "^0.5", "composer/installers": "^2" }, "require-dev": { diff --git a/config/defaults.php b/config/defaults.php deleted file mode 100644 index 95e6c01..0000000 --- a/config/defaults.php +++ /dev/null @@ -1,74 +0,0 @@ - [ - [ - 'parent_slug' => 'options-general.php', - 'page_title' => static fn(): string => __( 'Plugin Slug Settings', 'plugin-slug' ), - 'menu_title' => static fn(): string => __( 'Plugin Slug', 'plugin-slug' ), - 'capability' => 'manage_options', - 'menu_slug' => 'plugin-slug', - 'view' => PLUGIN_SLUG_DIR . 'views/admin-page.php', - ], - ], - 'settings' => [ - 'setting1' => [ - 'option_group' => 'pluginslug', - 'sanitize_callback' => static function ( mixed $value ): array { - if ( ! is_array( $value ) ) { - return []; - } - - return array_map( 'sanitize_text_field', $value ); - }, - 'sections' => [ - 'section1' => [ - 'title' => static fn(): string => __( 'My Section Title', 'plugin-slug' ), - 'view' => PLUGIN_SLUG_DIR . 'views/section1.php', - 'fields' => [ - 'field1' => [ - 'title' => static fn(): string => __( 'My Field Title', 'plugin-slug' ), - 'view' => PLUGIN_SLUG_DIR . 'views/field1.php', - ], - ], - ], - ], - ], - ], -]; - -$plugin_slug_assets = [ - 'admin' => [ - // The .asset.php file is generated by `npm run build` and lists the - // script's WordPress dependencies plus a content-hash version. - 'asset_file' => PLUGIN_SLUG_DIR . 'build/admin-settings.asset.php', - 'script' => plugins_url( 'build/admin-settings.js', PLUGIN_SLUG_FILE ), - 'style' => plugins_url( 'build/admin-settings.css', PLUGIN_SLUG_FILE ), - ], -]; - -return [ - 'Gamajo' => [ - 'PluginSlug' => [ - 'Settings' => $plugin_slug_settings, - 'Assets' => $plugin_slug_assets, - ], - ], -]; diff --git a/phpstan.neon.dist b/phpstan.neon.dist index c0ae816..19a8e1d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -3,16 +3,11 @@ # The WordPress stubs and rules are loaded automatically from # szepeviktor/phpstan-wordpress via phpstan/extension-installer. # -# views/ and config/ are deliberately not analysed: those files are pulled in -# via include/require and rely on variables injected into their scope at -# runtime (template data, plugin constants), which PHPStan cannot see. -# -# Level 8 is the ceiling for this codebase: level 9 adds checkExplicitMixed, -# which fights the untyped boundary of config-driven WordPress code — the -# BrightNucleus Config accessors return mixed by design. Revisit if the -# config ever gains a typed accessor layer. +# views/ are deliberately not analysed: those files are pulled in via require +# and rely on variables injected into their scope at runtime (template data), +# which PHPStan cannot see. parameters: - level: 8 + level: max paths: - plugin-slug.php - src diff --git a/plugin-slug.php b/plugin-slug.php index 4ce7bfc..958deea 100644 --- a/plugin-slug.php +++ b/plugin-slug.php @@ -29,21 +29,11 @@ namespace Gamajo\PluginSlug; -use BrightNucleus\Config\ConfigFactory; - // If this file is called directly, abort. if ( ! defined( 'WPINC' ) ) { die; } -if ( ! defined( 'PLUGIN_SLUG_FILE' ) ) { - define( 'PLUGIN_SLUG_FILE', __FILE__ ); -} - -if ( ! defined( 'PLUGIN_SLUG_DIR' ) ) { - define( 'PLUGIN_SLUG_DIR', plugin_dir_path( __FILE__ ) ); -} - // Load Composer autoloader. if ( file_exists( __DIR__ . '/vendor/autoload.php' ) ) { require_once __DIR__ . '/vendor/autoload.php'; @@ -70,10 +60,8 @@ static function (): void { function plugin_slug(): Plugin { static $plugin = null; - if ( null === $plugin ) { - $plugin = new Plugin( - ConfigFactory::create( __DIR__ . '/config/defaults.php' )->getSubConfig( 'Gamajo\PluginSlug' ) - ); + if ( ! $plugin instanceof Plugin ) { + $plugin = new Plugin( __FILE__ ); } return $plugin; diff --git a/src/Plugin.php b/src/Plugin.php index 0e98125..d6bdf08 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -12,13 +12,13 @@ namespace Gamajo\PluginSlug; -use BrightNucleus\Config\ConfigInterface; -use BrightNucleus\Config\ConfigTrait; -use BrightNucleus\Config\Exception\FailedToProcessConfigException; - /** * Main plugin class. * + * Registers an example settings page directly against the WordPress API. + * Swap these registrations for your own; the boilerplate keeps them here, + * fully typed, rather than behind a configuration abstraction. + * * @since 0.1.0 * * @package Gamajo\PluginSlug @@ -26,7 +26,33 @@ */ class Plugin { - use ConfigTrait; + /** + * Settings page slug, and the page the settings are shown on. + * + * @since 0.2.0 + */ + private const string MENU_SLUG = 'plugin-slug'; + + /** + * Option group the settings are registered against. + * + * @since 0.2.0 + */ + private const string OPTION_GROUP = 'plugin_slug'; + + /** + * Name of the single option the settings are stored in. + * + * @since 0.2.0 + */ + private const string OPTION_NAME = 'plugin_slug_settings'; + + /** + * Section identifier on the settings page. + * + * @since 0.2.0 + */ + private const string SECTION_ID = 'plugin_slug_section_general'; /** * Handle shared by the admin settings script and style. @@ -36,28 +62,36 @@ class Plugin { private const string ASSET_HANDLE = 'plugin-slug-admin-settings'; /** - * Admin page hook suffixes returned by add_submenu_page(). + * Absolute path to the plugin directory, with a trailing slash. * - * Captured when the pages are registered so assets load only on the - * plugin's own screens, rather than on every admin page. + * @since 0.2.0 + */ + private readonly string $dir; + + /** + * URL to the plugin directory, with a trailing slash. * * @since 0.2.0 + */ + private readonly string $url; + + /** + * Hook suffix of the settings page, set once it is registered. * - * @var string[] + * @since 0.2.0 */ - private array $page_hooks = []; + private string $hook_suffix = ''; /** * Instantiate a Plugin object. * - * @since 0.1.0 - * - * @throws FailedToProcessConfigException If the Config could not be parsed correctly. + * @since 0.2.0 * - * @param ConfigInterface $config Config to parametrize the object. + * @param string $file Absolute path to the main plugin file. */ - public function __construct( ConfigInterface $config ) { - $this->processConfig( $config ); + public function __construct( string $file ) { + $this->dir = plugin_dir_path( $file ); + $this->url = plugin_dir_url( $file ); } /** @@ -66,116 +100,159 @@ public function __construct( ConfigInterface $config ) { * @since 0.1.0 */ public function run(): void { - add_action( 'admin_menu', $this->register_admin_pages( ... ) ); + add_action( 'admin_menu', $this->register_settings_page( ... ) ); add_action( 'admin_init', $this->register_settings( ... ) ); add_action( 'admin_enqueue_scripts', $this->enqueue_admin_assets( ... ) ); } /** - * Register the plugin admin pages from the config. + * Add the settings page under the Settings admin menu. * - * Runs on the admin_menu hook, which fires after init, so the page and - * menu title closures can be safely resolved to translated strings here. + * Runs on admin_menu, after init, so the translated titles resolve here. * - * @since 0.1.0 + * @since 0.2.0 */ - public function register_admin_pages(): void { - foreach ( $this->getConfigKey( 'Settings', 'submenu_pages' ) as $page ) { - $hook_suffix = add_submenu_page( - $page['parent_slug'], - $page['page_title'](), - $page['menu_title'](), - $page['capability'], - $page['menu_slug'], - static function () use ( $page ): void { - require $page['view']; - } - ); - - if ( $hook_suffix ) { - $this->page_hooks[] = $hook_suffix; - } + public function register_settings_page(): void { + $hook_suffix = add_options_page( + __( 'Plugin Slug Settings', 'plugin-slug' ), + __( 'Plugin Slug', 'plugin-slug' ), + 'manage_options', + self::MENU_SLUG, + $this->render_settings_page( ... ) + ); + + if ( $hook_suffix ) { + $this->hook_suffix = $hook_suffix; } } /** - * Register settings, sections and fields from the config. + * Render the settings page wrapper. * - * Each field passes a label_for argument that matches the id of the - * input in the field view, so the rendered field title becomes a label - * for the form control. + * @since 0.2.0 + */ + private function render_settings_page(): void { + require $this->dir . 'views/admin-page.php'; + } + + /** + * Register the setting, its section and its field. + * + * 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 */ public function register_settings(): void { - foreach ( $this->getConfigKey( 'Settings', 'settings' ) as $option_name => $setting ) { - register_setting( - $setting['option_group'], - $option_name, - [ - 'sanitize_callback' => $setting['sanitize_callback'], - ] - ); - - foreach ( $setting['sections'] as $section_id => $section ) { - add_settings_section( - $section_id, - $section['title'](), - static function () use ( $section ): void { - require $section['view']; - }, - $setting['option_group'] - ); - - foreach ( $section['fields'] as $field_id => $field ) { - add_settings_field( - $field_id, - $field['title'](), - // $args is used by the view file pulled in via require. - // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found - static function ( array $args ) use ( $field ): void { - require $field['view']; - }, - $setting['option_group'], - $section_id, - [ - 'label_for' => $field_id, - 'option_name' => $option_name, - ] - ); - } - } + register_setting( + self::OPTION_GROUP, + self::OPTION_NAME, + [ + 'type' => 'array', + 'sanitize_callback' => $this->sanitize_settings( ... ), + 'default' => [], + ] + ); + + add_settings_section( + self::SECTION_ID, + __( 'My Section Title', 'plugin-slug' ), + $this->render_section( ... ), + self::MENU_SLUG + ); + + add_settings_field( + 'field1', + __( 'My Field Title', 'plugin-slug' ), + $this->render_field( ... ), + self::MENU_SLUG, + self::SECTION_ID, + [ + 'label_for' => 'field1', + 'option_name' => self::OPTION_NAME, + ] + ); + } + + /** + * Sanitize the submitted settings. + * + * @since 0.2.0 + * + * @param mixed $value Raw value submitted for the option. + * @return array Sanitized settings. + */ + public function sanitize_settings( mixed $value ): array { + if ( ! is_array( $value ) ) { + return []; + } + + $sanitized = []; + + foreach ( $value as $key => $field ) { + $sanitized[ $key ] = sanitize_text_field( is_scalar( $field ) ? (string) $field : '' ); } + + return $sanitized; + } + + /** + * Render the settings section description. + * + * @since 0.2.0 + */ + private function render_section(): void { + require $this->dir . 'views/section1.php'; + } + + /** + * Render the example settings field. + * + * @since 0.2.0 + * + * @param array{label_for: string, option_name: string} $args Field arguments. + */ + private function render_field( array $args ): void { + $label_for = $args['label_for']; + $option_name = $args['option_name']; + + require $this->dir . 'views/field1.php'; } /** * Enqueue the admin settings script and style. * - * Loads only on the plugin's own admin screens. The asset paths come from - * the config; the build/*.asset.php file they point at is generated during - * the npm build and supplies the script's WordPress package dependencies - * and a content-hash version for cache busting, so neither has to be - * maintained by hand. + * Loads only on the plugin's own settings screen. The build/*.asset.php + * file is generated during the npm build and supplies the script's + * WordPress package dependencies and a content-hash version for cache + * busting, so neither has to be maintained by hand. * * @since 0.2.0 * * @param string $hook_suffix Admin page identifier passed by WordPress. */ public function enqueue_admin_assets( string $hook_suffix ): void { - if ( ! in_array( $hook_suffix, $this->page_hooks, true ) ) { + if ( $hook_suffix !== $this->hook_suffix ) { return; } + $asset_file = $this->dir . 'build/admin-settings.asset.php'; + // Built assets are absent until `npm run build` has run. - if ( ! file_exists( $this->getConfigKey( 'Assets', 'admin', 'asset_file' ) ) ) { + if ( ! is_file( $asset_file ) ) { return; } - $asset = require $this->getConfigKey( 'Assets', 'admin', 'asset_file' ); + /** + * Script dependencies and version, in the shape @wordpress/scripts writes. + * + * @var array{dependencies: list, version: string} $asset + */ + $asset = require $asset_file; wp_enqueue_script( self::ASSET_HANDLE, - $this->getConfigKey( 'Assets', 'admin', 'script' ), + $this->url . 'build/admin-settings.js', $asset['dependencies'], $asset['version'], [ 'in_footer' => true ] @@ -186,7 +263,7 @@ public function enqueue_admin_assets( string $hook_suffix ): void { wp_enqueue_style( self::ASSET_HANDLE, - $this->getConfigKey( 'Assets', 'admin', 'style' ), + $this->url . 'build/admin-settings.css', [], $asset['version'] ); diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php index 742c947..1052a03 100644 --- a/tests/Unit/PluginTest.php +++ b/tests/Unit/PluginTest.php @@ -13,7 +13,6 @@ namespace Gamajo\PluginSlug\Tests\Unit; use Brain\Monkey\Functions; -use BrightNucleus\Config\Config; use Closure; use Gamajo\PluginSlug\Plugin as Testee; use Gamajo\PluginSlug\Tests\TestCase; @@ -23,18 +22,17 @@ /** * Plugin test case. * - * Drives the registration of admin pages, settings, sections and fields from a - * BrightNucleus Config, asserting both the exact arguments passed to the - * WordPress registration functions and the behaviour of the closures captured - * by those functions. The closures require view files, so the test Config - * points the 'view' values at fixtures that echo a known marker. + * Asserts the exact arguments passed to the WordPress registration functions + * and the behaviour of the render callbacks captured by them. The callbacks + * require view files, so the plugin directory is pointed at fixtures that echo + * a known marker. * * @covers \Gamajo\PluginSlug\Plugin */ class PluginTest extends TestCase { /** - * Directory holding the view fixtures required by the render closures. + * Directory holding the view and build fixtures. */ private string $fixtures; @@ -46,103 +44,72 @@ protected function setUp(): void { $this->fixtures = dirname( __DIR__ ) . '/fixtures/'; - // The field fixture mirrors the real view and escapes its output. - Functions\when( 'esc_html' )->returnArg(); + // Titles are passed through unchanged so exact-argument assertions read clearly. + Functions\when( '__' )->returnArg(); } /** - * Build the test Config consumed by the Plugin. + * Build a Plugin whose directory and URL are stubbed. * - * The shape mirrors config/defaults.php but stays minimal: one submenu page - * and one setting that contains one section with one field. Translatable - * labels are closures and 'view' values point at the fixtures. + * @param string|null $dir Directory the plugin should resolve to. Defaults + * to the fixtures directory. + * @return Testee Plugin under test. + */ + private function make_plugin( ?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' ); + } + + /** + * Register the settings page so the captured hook suffix is available. * - * @return Config Config to parametrize the Plugin. + * @param Testee $plugin Plugin under test. + * @param string|bool $hook_suffix Value add_options_page() should return. */ - private function make_config(): Config { - return new Config( - [ - 'Settings' => [ - 'submenu_pages' => [ - [ - 'parent_slug' => 'options-general.php', - 'page_title' => static fn(): string => 'Page Title', - 'menu_title' => static fn(): string => 'Menu Title', - 'capability' => 'manage_options', - 'menu_slug' => 'plugin-slug', - 'view' => $this->fixtures . 'page.php', - ], - ], - 'settings' => [ - 'option1' => [ - 'option_group' => 'pluginslug', - 'sanitize_callback' => static fn( $value ): string => 'sanitized:' . $value, - 'sections' => [ - 'section1' => [ - 'title' => static fn(): string => 'Section Title', - 'view' => $this->fixtures . 'section.php', - 'fields' => [ - 'field1' => [ - 'title' => static fn(): string => 'Field Title', - 'view' => $this->fixtures . 'field.php', - ], - ], - ], - ], - ], - ], - ], - 'Assets' => [ - 'admin' => [ - 'asset_file' => $this->fixtures . 'build/admin-settings.asset.php', - 'script' => 'https://example.test/build/admin-settings.js', - 'style' => 'https://example.test/build/admin-settings.css', - ], - ], - ] - ); + private function register_page_returning( Testee $plugin, string|bool $hook_suffix ): void { + Functions\expect( 'add_options_page' )->once()->andReturn( $hook_suffix ); + + $plugin->register_settings_page(); } /** - * Capture the output produced by invoking a render closure. + * Capture the output produced by invoking a render callback. * - * @param Closure $closure Render closure captured from a registration call. - * @param array ...$args Arguments to pass to the closure. + * @param Closure $callback Callback captured from a registration call. + * @param array ...$args Arguments to pass to the callback. * * @return string Buffered output. */ - private function capture( Closure $closure, array ...$args ): string { + private function capture( Closure $callback, array ...$args ): string { ob_start(); - $closure( ...$args ); + $callback( ...$args ); return (string) ob_get_clean(); } /** - * The run() method wires register_admin_pages() to the admin_menu hook. + * Build a Mockery matcher asserting a first-class callable wraps a method. + * + * @param Testee $plugin Plugin the callable should be bound to. + * @param string $method Method name the callable should wrap. + * + * @return object Mockery matcher. */ - public function test_run_hooks_register_admin_pages_onto_admin_menu(): void { - $plugin = new Testee( $this->make_config() ); - - Functions\expect( 'add_action' ) - ->once() - ->with( - 'admin_menu', - Mockery::on( - static function ( $candidate ) use ( $plugin ): bool { - if ( ! $candidate instanceof Closure ) { - return false; - } - - $reflection = new ReflectionFunction( $candidate ); + private function bound_to( Testee $plugin, string $method ): object { + return Mockery::on( + fn( $candidate ): bool => $this->is_bound_to( $candidate, $plugin, $method ) + ); + } - return 'register_admin_pages' === $reflection->getName() - && $plugin === $reflection->getClosureThis(); - } - ) - ); + /** + * The run() method wires register_settings_page() onto admin_menu. + */ + public function test_run_registers_settings_page_on_admin_menu(): void { + $plugin = $this->make_plugin(); - // The other hooks are added too, but are asserted by other tests. + Functions\expect( 'add_action' )->once()->with( 'admin_menu', $this->bound_to( $plugin, 'register_settings_page' ) ); Functions\expect( 'add_action' )->with( 'admin_init', Mockery::any() ); Functions\expect( 'add_action' )->with( 'admin_enqueue_scripts', Mockery::any() ); @@ -150,101 +117,65 @@ static function ( $candidate ) use ( $plugin ): bool { } /** - * The run() method wires register_settings() to the admin_init hook. + * The run() method wires register_settings() onto admin_init. */ - public function test_run_hooks_register_settings_onto_admin_init(): void { - $plugin = new Testee( $this->make_config() ); + public function test_run_registers_settings_on_admin_init(): void { + $plugin = $this->make_plugin(); 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' )->with( 'admin_enqueue_scripts', Mockery::any() ); - Functions\expect( 'add_action' ) - ->once() - ->with( - 'admin_init', - Mockery::on( - static function ( $candidate ) use ( $plugin ): bool { - if ( ! $candidate instanceof Closure ) { - return false; - } - - $reflection = new ReflectionFunction( $candidate ); - - return 'register_settings' === $reflection->getName() - && $plugin === $reflection->getClosureThis(); - } - ) - ); - $plugin->run(); } /** - * The run() method wires enqueue_admin_assets() to admin_enqueue_scripts. + * The run() method wires enqueue_admin_assets() onto admin_enqueue_scripts. */ - public function test_run_hooks_enqueue_admin_assets_onto_admin_enqueue_scripts(): void { - $plugin = new Testee( $this->make_config() ); + public function test_run_enqueues_assets_on_admin_enqueue_scripts(): void { + $plugin = $this->make_plugin(); 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', - Mockery::on( - static function ( $candidate ) use ( $plugin ): bool { - if ( ! $candidate instanceof Closure ) { - return false; - } - - $reflection = new ReflectionFunction( $candidate ); - - return 'enqueue_admin_assets' === $reflection->getName() - && $plugin === $reflection->getClosureThis(); - } - ) - ); + Functions\expect( 'add_action' )->once()->with( 'admin_enqueue_scripts', $this->bound_to( $plugin, 'enqueue_admin_assets' ) ); $plugin->run(); } /** - * Each submenu page is registered with exact arguments. + * The settings page is added under the Settings menu with exact arguments. */ - public function test_register_admin_pages_passes_exact_arguments(): void { - $plugin = new Testee( $this->make_config() ); + public function test_register_settings_page_adds_options_page_with_exact_arguments(): void { + $plugin = $this->make_plugin(); - Functions\expect( 'add_submenu_page' ) + Functions\expect( 'add_options_page' ) ->once() ->with( - 'options-general.php', - 'Page Title', - 'Menu Title', + 'Plugin Slug Settings', + 'Plugin Slug', 'manage_options', 'plugin-slug', Mockery::type( Closure::class ) - ); + ) + ->andReturn( 'settings_page_plugin-slug' ); - $plugin->register_admin_pages(); + $plugin->register_settings_page(); } /** - * The page render closure requires the configured view file. + * The settings page callback renders the page view. */ - public function test_register_admin_pages_render_closure_requires_view(): void { - $plugin = new Testee( $this->make_config() ); - + public function test_settings_page_callback_renders_the_view(): void { + $plugin = $this->make_plugin(); $captured = null; - Functions\expect( 'add_submenu_page' ) + Functions\expect( 'add_options_page' ) ->once() ->with( Mockery::any(), Mockery::any(), Mockery::any(), Mockery::any(), - Mockery::any(), Mockery::on( static function ( $candidate ) use ( &$captured ): bool { $captured = $candidate; @@ -252,32 +183,31 @@ static function ( $candidate ) use ( &$captured ): bool { return $candidate instanceof Closure; } ) - ); + ) + ->andReturn( 'settings_page_plugin-slug' ); - $plugin->register_admin_pages(); + $plugin->register_settings_page(); static::assertInstanceOf( Closure::class, $captured ); - static::assertSame( 'PAGE_FIXTURE_OUTPUT', $this->capture( $captured ) ); + static::assertSame( 'ADMIN_PAGE_FIXTURE', $this->capture( $captured ) ); } /** * The setting is registered with exact arguments. */ public function test_register_settings_registers_setting_with_exact_arguments(): void { - $config = $this->make_config(); - $plugin = new Testee( $config ); - - $sanitize = $config->getKey( [ 'Settings', 'settings', 'option1', 'sanitize_callback' ] ); + $plugin = $this->make_plugin(); Functions\expect( 'register_setting' ) ->once() ->with( - 'pluginslug', - 'option1', + 'plugin_slug', + 'plugin_slug_settings', Mockery::on( - static fn( $args ): bool => is_array( $args ) - && array_keys( $args ) === [ 'sanitize_callback' ] - && $args['sanitize_callback'] === $sanitize + fn( $args ): bool => is_array( $args ) + && 'array' === $args['type'] + && [] === $args['default'] + && $this->is_bound_to( $args['sanitize_callback'], $plugin, 'sanitize_settings' ) ) ); @@ -288,69 +218,33 @@ public function test_register_settings_registers_setting_with_exact_arguments(): } /** - * The sanitize callback wired into the setting is the one from the Config. + * The section is registered with exact arguments. */ - public function test_register_settings_uses_config_sanitize_callback(): void { - $config = $this->make_config(); - $plugin = new Testee( $config ); - - $captured = null; - - Functions\expect( 'register_setting' ) - ->once() - ->with( - Mockery::any(), - Mockery::any(), - Mockery::on( - static function ( array $args ) use ( &$captured ): bool { - $captured = $args['sanitize_callback'] ?? null; - - return true; - } - ) - ); - - Functions\expect( 'add_settings_section' )->andReturnNull(); - Functions\expect( 'add_settings_field' )->andReturnNull(); - - $plugin->register_settings(); - - static::assertInstanceOf( Closure::class, $captured ); - static::assertSame( 'sanitized:raw', $captured( 'raw' ) ); - } - - /** - * Each section is registered with exact arguments. - */ - public function test_register_settings_registers_section_with_exact_arguments(): void { - $plugin = new Testee( $this->make_config() ); + public function test_register_settings_adds_section_with_exact_arguments(): void { + $plugin = $this->make_plugin(); Functions\expect( 'register_setting' )->andReturnNull(); - Functions\expect( 'add_settings_section' ) ->once() ->with( - 'section1', - 'Section Title', + 'plugin_slug_section_general', + 'My Section Title', Mockery::type( Closure::class ), - 'pluginslug' + 'plugin-slug' ); - Functions\expect( 'add_settings_field' )->andReturnNull(); $plugin->register_settings(); } /** - * The section render closure requires the configured view file. + * The section callback renders the section view. */ - public function test_register_settings_section_closure_requires_view(): void { - $plugin = new Testee( $this->make_config() ); - + public function test_section_callback_renders_the_view(): void { + $plugin = $this->make_plugin(); $captured = null; Functions\expect( 'register_setting' )->andReturnNull(); - Functions\expect( 'add_settings_section' ) ->once() ->with( @@ -365,35 +259,33 @@ static function ( $candidate ) use ( &$captured ): bool { ), Mockery::any() ); - Functions\expect( 'add_settings_field' )->andReturnNull(); $plugin->register_settings(); static::assertInstanceOf( Closure::class, $captured ); - static::assertSame( 'SECTION_FIXTURE_OUTPUT', $this->capture( $captured ) ); + static::assertSame( 'SECTION_FIXTURE', $this->capture( $captured ) ); } /** - * Each field is registered with exact arguments. + * The field is registered with exact arguments. */ - public function test_register_settings_registers_field_with_exact_arguments(): void { - $plugin = new Testee( $this->make_config() ); + public function test_register_settings_adds_field_with_exact_arguments(): void { + $plugin = $this->make_plugin(); Functions\expect( 'register_setting' )->andReturnNull(); Functions\expect( 'add_settings_section' )->andReturnNull(); - Functions\expect( 'add_settings_field' ) ->once() ->with( 'field1', - 'Field Title', + 'My Field Title', Mockery::type( Closure::class ), - 'pluginslug', - 'section1', + 'plugin-slug', + 'plugin_slug_section_general', [ 'label_for' => 'field1', - 'option_name' => 'option1', + 'option_name' => 'plugin_slug_settings', ] ); @@ -401,16 +293,14 @@ public function test_register_settings_registers_field_with_exact_arguments(): v } /** - * The field render closure requires the view and receives the $args array. + * The field callback renders the field view with the args passed through. */ - public function test_register_settings_field_closure_requires_view_with_args(): void { - $plugin = new Testee( $this->make_config() ); - + public function test_field_callback_renders_the_view_with_args(): void { + $plugin = $this->make_plugin(); $captured = null; Functions\expect( 'register_setting' )->andReturnNull(); Functions\expect( 'add_settings_section' )->andReturnNull(); - Functions\expect( 'add_settings_field' ) ->once() ->with( @@ -436,35 +326,63 @@ static function ( $candidate ) use ( &$captured ): bool { $captured, [ 'label_for' => 'field1', - 'option_name' => 'option1', + 'option_name' => 'plugin_slug_settings', ] ); - static::assertSame( 'FIELD_FIXTURE_OUTPUT:field1:option1', $output ); + static::assertSame( 'FIELD_FIXTURE:field1:plugin_slug_settings', $output ); } /** - * Register the admin pages with a stubbed hook suffix. - * - * Captures the suffix into the plugin's page_hooks so the enqueue tests - * can drive the on-screen check, mirroring how WordPress returns a hook - * suffix from add_submenu_page(). - * - * @param Testee $plugin Plugin under test. - * @param string $hook_suffix Hook suffix add_submenu_page() should return. + * Non-array input sanitizes to an empty array. */ - private function register_pages_returning( Testee $plugin, string $hook_suffix ): void { - Functions\expect( 'add_submenu_page' )->once()->andReturn( $hook_suffix ); + public function test_sanitize_settings_returns_empty_array_for_non_array(): void { + static::assertSame( [], $this->make_plugin()->sanitize_settings( 'not-an-array' ) ); + } - $plugin->register_admin_pages(); + /** + * Each scalar field is passed through sanitize_text_field. + */ + public function test_sanitize_settings_sanitizes_each_scalar_field(): void { + // Mockery::type( 'string' ) asserts each field is cast to a string before + // it reaches sanitize_text_field, which under strict_types it must be. + Functions\expect( 'sanitize_text_field' ) + ->twice() + ->with( Mockery::type( 'string' ) ) + ->andReturnUsing( static fn( string $value ): string => 'clean:' . $value ); + + static::assertSame( + [ + 'field1' => 'clean:hello', + 'field2' => 'clean:42', + ], + $this->make_plugin()->sanitize_settings( + [ + 'field1' => 'hello', + 'field2' => 42, + ] + ) + ); + } + + /** + * Non-scalar fields are replaced with an empty string before sanitizing. + */ + public function test_sanitize_settings_replaces_non_scalar_fields(): void { + Functions\expect( 'sanitize_text_field' )->andReturnArg( 0 ); + + static::assertSame( + [ 'field1' => '' ], + $this->make_plugin()->sanitize_settings( [ 'field1' => [ 'nested' ] ] ) + ); } /** * Assets are not enqueued on admin pages other than the plugin's own. */ - public function test_enqueue_admin_assets_skips_pages_outside_the_plugin(): void { - $plugin = new Testee( $this->make_config() ); - $this->register_pages_returning( $plugin, 'settings_page_plugin-slug' ); + public function test_enqueue_skips_other_pages(): void { + $plugin = $this->make_plugin(); + $this->register_page_returning( $plugin, 'settings_page_plugin-slug' ); Functions\expect( 'wp_enqueue_script' )->never(); Functions\expect( 'wp_enqueue_style' )->never(); @@ -472,12 +390,38 @@ public function test_enqueue_admin_assets_skips_pages_outside_the_plugin(): void $plugin->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 ); + + Functions\expect( 'wp_enqueue_script' )->never(); + Functions\expect( 'wp_enqueue_style' )->never(); + + $plugin->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' ); + + Functions\expect( 'wp_enqueue_script' )->never(); + Functions\expect( 'wp_enqueue_style' )->never(); + + $plugin->enqueue_admin_assets( 'settings_page_plugin-slug' ); + } + /** * The built script and style are enqueued on the plugin's own page. */ - public function test_enqueue_admin_assets_enqueues_the_built_script_and_style(): void { - $plugin = new Testee( $this->make_config() ); - $this->register_pages_returning( $plugin, 'settings_page_plugin-slug' ); + public function test_enqueue_enqueues_the_built_script_and_style(): void { + $plugin = $this->make_plugin(); + $this->register_page_returning( $plugin, 'settings_page_plugin-slug' ); Functions\expect( 'wp_enqueue_script' ) ->once() @@ -508,4 +452,21 @@ public function test_enqueue_admin_assets_enqueues_the_built_script_and_style(): $plugin->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 string $method Method name the callable should wrap. + */ + private function is_bound_to( mixed $candidate, Testee $plugin, string $method ): bool { + if ( ! $candidate instanceof Closure ) { + return false; + } + + $reflection = new ReflectionFunction( $candidate ); + + return $method === $reflection->getName() && $plugin === $reflection->getClosureThis(); + } } diff --git a/tests/fixtures/field.php b/tests/fixtures/field.php deleted file mode 100644 index c5b77da..0000000 --- a/tests/fixtures/field.php +++ /dev/null @@ -1,20 +0,0 @@ -
- +

diff --git a/views/field1.php b/views/field1.php index 99f0fcd..9657a82 100644 --- a/views/field1.php +++ b/views/field1.php @@ -2,9 +2,9 @@ /** * Admin page section field view * - * Receives the $args array passed to add_settings_field(), containing - * label_for and option_name keys. The input id matches the label_for - * value, so the field title rendered by WordPress labels this control. + * Receives $label_for and $option_name from Plugin::render_field(). The input + * id matches $label_for, so the field title rendered by WordPress labels this + * control. * * @package Gamajo\PluginSlug * @author Gary Jones @@ -14,13 +14,13 @@ declare( strict_types = 1 ); -$plugin_slug_options = (array) get_option( $args['option_name'], [] ); -$plugin_slug_value = (string) ( $plugin_slug_options[ $args['label_for'] ] ?? '' ); +$plugin_slug_options = (array) get_option( $option_name, [] ); +$plugin_slug_value = (string) ( $plugin_slug_options[ $label_for ] ?? '' ); ?>