From d5a2959a31aa6a8efba17c9eef0dd229f73dad35 Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Thu, 11 Jun 2026 12:15:53 +0100 Subject: [PATCH 1/7] fix: correct plugin header metadata WordPress core only recognises 'Requires at least' for the minimum WordPress version, so the non-standard 'Requires WP' header left the 6.9 floor silently unenforced. Rename it, fill the Plugin URI and GitHub Plugin URI placeholders with the real repository URL, note that the latter is read by Git Updater (formerly GitHub Updater), and point the License URI at the canonical HTTPS GPL-2.0 page. --- plugin-slug.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/plugin-slug.php b/plugin-slug.php index 1758cd6..afe03cf 100644 --- a/plugin-slug.php +++ b/plugin-slug.php @@ -2,24 +2,27 @@ /** * Plugin Name * + * The GitHub Plugin URI header below is read by Git Updater (formerly + * GitHub Updater) to serve plugin updates directly from GitHub. + * * @package Gamajo\PluginSlug * @author Gary Jones - * @copyright 2024 Gary Jones + * @copyright 2024-2026 Gary Jones * @license GPL-2.0-or-later * * @wordpress-plugin * Plugin Name: Plugin Boilerplate - * Plugin URI: https://github.com/garyjones/... + * Plugin URI: https://github.com/GaryJones/plugin-slug * Description: ... * Version: 0.1.0 * Author: Gary Jones * Author URI: https://garyjones.io * Text Domain: plugin-slug * License: GPL-2.0-or-later - * License URI: http://www.gnu.org/licenses/gpl-2.0.txt - * GitHub Plugin URI: https://github.com/garyjones/... + * License URI: https://www.gnu.org/licenses/gpl-2.0.html + * GitHub Plugin URI: https://github.com/GaryJones/plugin-slug * Requires PHP: 8.4 - * Requires WP: 6.9 + * Requires at least: 6.9 */ declare( strict_types = 1 ); From 4e03e5dc0c03d6ff71ca02213ce7e449c918f388 Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Thu, 11 Jun 2026 12:16:11 +0100 Subject: [PATCH 2/7] refactor: boot plugin on a hook instead of at include time Building the Config and assigning a Plugin instance to $GLOBALS at file load ran code as a side effect of inclusion, and the __() calls in config/defaults.php executed before init, which triggers the _load_textdomain_just_in_time notice on WordPress 6.7+. The plugin now boots on plugins_loaded behind a plugin_slug() accessor, and translatable labels are closures resolved during the admin_menu and admin_init callbacks. Plugin::run() previously did nothing, so most of the config was dead: it now registers the admin page, setting, section and field from the config, meaning every remaining key is consumed by real code. The unused js/admin-page.js and languages_dir entries go away (no js/ or languages/ directory exists), along with the import of the uninstalled BrightNucleus Settings package, the never-assigned static $instance property, and the docblock advertising a non-existent get_instance() method. The now-unused PLUGIN_SLUG_URL constant is dropped too. Each field passes label_for matching the input id in its view, so the field title labels the control, and the previously hard-coded English strings in the section and field views are now translatable. --- config/defaults.php | 69 +++++++++++------------------------ plugin-slug.php | 36 +++++++++++++++---- src/Plugin.php | 88 ++++++++++++++++++++++++++++++++++++++------- views/field1.php | 18 ++++++++-- views/section1.php | 4 +-- 5 files changed, 144 insertions(+), 71 deletions(-) diff --git a/config/defaults.php b/config/defaults.php index b6f9e21..89bbd26 100644 --- a/config/defaults.php +++ b/config/defaults.php @@ -2,9 +2,14 @@ /** * Plugin configuration file * + * Translatable labels are closures, so the translation functions only run + * when the labels are resolved during hook callbacks (after init). Calling + * translation functions while this file loads would trigger the + * _load_textdomain_just_in_time notice in WordPress 6.7+. + * * @package Gamajo\PluginSlug * @author Gary Jones - * @copyright 2024 Gary Jones + * @copyright 2024-2026 Gary Jones * @license GPL-2.0-or-later */ @@ -12,65 +17,34 @@ namespace Gamajo\PluginSlug; -$plugin_slug_plugin = [ - 'textdomain' => 'plugin-slug', - 'languages_dir' => 'languages', -]; - $plugin_slug_settings = [ 'submenu_pages' => [ [ - 'parent_slug' => 'options-general.php', - 'page_title' => __( 'Plugin Slug Settings', 'plugin-slug' ), - 'menu_title' => __( 'Plugin Slug', 'plugin-slug' ), - 'capability' => 'manage_options', - 'menu_slug' => 'plugin-slug', - 'view' => PLUGIN_SLUG_DIR . 'views/admin-page.php', - 'dependencies' => [ - 'styles' => [], - 'scripts' => [ - [ - 'handle' => 'plugin-slug-js', - 'src' => PLUGIN_SLUG_URL . 'js/admin-page.js', - 'deps' => [ 'jquery' ], - 'ver' => '1.2.3', - 'in_footer' => true, - 'is_needed' => function ( $context ): bool { - if ( $context ) { - return false; - } - - return true; - }, - 'localize' => [ - 'name' => 'pluginSlugI18n', - 'data' => function ( $context ): array { - return [ - 'test_localize_data' => 'test_localize_value', - 'context' => $context, - ]; - }, - ], - ], - ], - 'handlers' => [ - 'scripts' => 'BrightNucleus\Dependency\ScriptHandler', - 'styles' => 'BrightNucleus\Dependency\StyleHandler', - ], - ], + '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' => null, + 'sanitize_callback' => static function ( mixed $value ): array { + if ( ! is_array( $value ) ) { + return []; + } + + return array_map( 'sanitize_text_field', $value ); + }, 'sections' => [ 'section1' => [ - 'title' => __( 'My Section Title', 'plugin-slug' ), + 'title' => static fn(): string => __( 'My Section Title', 'plugin-slug' ), 'view' => PLUGIN_SLUG_DIR . 'views/section1.php', 'fields' => [ 'field1' => [ - 'title' => __( 'My Field Title', 'plugin-slug' ), + 'title' => static fn(): string => __( 'My Field Title', 'plugin-slug' ), 'view' => PLUGIN_SLUG_DIR . 'views/field1.php', ], ], @@ -83,7 +57,6 @@ return [ 'Gamajo' => [ 'PluginSlug' => [ - 'Plugin' => $plugin_slug_plugin, 'Settings' => $plugin_slug_settings, ], ], diff --git a/plugin-slug.php b/plugin-slug.php index afe03cf..b9202c7 100644 --- a/plugin-slug.php +++ b/plugin-slug.php @@ -40,15 +40,37 @@ define( 'PLUGIN_SLUG_DIR', plugin_dir_path( __FILE__ ) ); } -if ( ! defined( 'PLUGIN_SLUG_URL' ) ) { - define( 'PLUGIN_SLUG_URL', plugin_dir_url( __FILE__ ) ); -} - // Load Composer autoloader. if ( file_exists( __DIR__ . '/vendor/autoload.php' ) ) { require_once __DIR__ . '/vendor/autoload.php'; } -// Initialize the plugin. -$GLOBALS['plugin_slug'] = new Plugin( ConfigFactory::create( __DIR__ . '/config/defaults.php' )->getSubConfig( 'Gamajo\PluginSlug' ) ); -$GLOBALS['plugin_slug']->run(); +// Initialize the plugin on a hook, rather than at file include time. +add_action( + 'plugins_loaded', + static function (): void { + plugin_slug()->run(); + } +); + +/** + * 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 ( null === $plugin ) { + $plugin = new Plugin( + ConfigFactory::create( __DIR__ . '/config/defaults.php' )->getSubConfig( 'Gamajo\PluginSlug' ) + ); + } + + return $plugin; +} diff --git a/src/Plugin.php b/src/Plugin.php index 35070cd..0b0a080 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -4,7 +4,7 @@ * * @package Gamajo\PluginSlug * @author Gary Jones - * @copyright 2024 Gary Jones + * @copyright 2024-2026 Gary Jones * @license GPL-2.0-or-later */ @@ -15,7 +15,6 @@ use BrightNucleus\Config\ConfigInterface; use BrightNucleus\Config\ConfigTrait; use BrightNucleus\Config\Exception\FailedToProcessConfigException; -use BrightNucleus\Settings\Settings; /** * Main plugin class. @@ -29,19 +28,9 @@ class Plugin { use ConfigTrait; - /** - * Static instance of the plugin. - * - * @since 0.1.0 - */ - protected static Plugin $instance; - /** * Instantiate a Plugin object. * - * Don't call the constructor directly, use the `Plugin::get_instance()` - * static method instead. - * * @since 0.1.0 * * @throws FailedToProcessConfigException If the Config could not be parsed correctly. @@ -58,5 +47,80 @@ public function __construct( ConfigInterface $config ) { * @since 0.1.0 */ public function run(): void { + add_action( 'admin_menu', [ $this, 'register_admin_pages' ] ); + add_action( 'admin_init', [ $this, 'register_settings' ] ); + } + + /** + * Register the plugin admin pages from the config. + * + * 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. + * + * @since 0.1.0 + */ + public function register_admin_pages(): void { + foreach ( $this->getConfigKey( 'Settings', 'submenu_pages' ) as $page ) { + 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']; + } + ); + } + } + + /** + * Register settings, sections and fields from the config. + * + * 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.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, + ] + ); + } + } + } } } diff --git a/views/field1.php b/views/field1.php index 47f0659..99f0fcd 100644 --- a/views/field1.php +++ b/views/field1.php @@ -2,12 +2,26 @@ /** * 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. + * * @package Gamajo\PluginSlug * @author Gary Jones - * @copyright 2024 Gary Jones + * @copyright 2024-2026 Gary Jones * @license GPL-2.0-or-later */ declare( strict_types = 1 ); + +$plugin_slug_options = (array) get_option( $args['option_name'], [] ); +$plugin_slug_value = (string) ( $plugin_slug_options[ $args['label_for'] ] ?? '' ); ?> -

This is the field 1 view.

+ +

diff --git a/views/section1.php b/views/section1.php index 0109ef0..589232f 100644 --- a/views/section1.php +++ b/views/section1.php @@ -4,10 +4,10 @@ * * @package Gamajo\PluginSlug * @author Gary Jones - * @copyright 2024 Gary Jones + * @copyright 2024-2026 Gary Jones * @license GPL-2.0-or-later */ declare( strict_types = 1 ); ?> -

This is the section 1 view.

+

From 9395e617c687175eaabe753c641b0237615c868c Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Thu, 11 Jun 2026 12:16:19 +0100 Subject: [PATCH 3/7] docs: complete Foo docblocks Add the missing @since and @return tags so the example class demonstrates fully documented methods. --- src/Foo.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Foo.php b/src/Foo.php index 162f6a1..2cc8a9e 100644 --- a/src/Foo.php +++ b/src/Foo.php @@ -4,7 +4,7 @@ * * @package Gamajo\PluginSlug * @author Gary Jones - * @copyright 2024 Gary Jones + * @copyright 2024-2026 Gary Jones * @license GPL-2.0-or-later */ @@ -14,10 +14,16 @@ /** * Foo class. + * + * @since 0.1.0 */ class Foo { /** * Bar. + * + * @since 0.1.0 + * + * @return string Identification of the method being called. */ public function bar(): string { return 'Foo::bar()'; @@ -26,6 +32,8 @@ public function bar(): string { /** * Returns true, always. * + * @since 0.1.0 + * * @return true */ public function is_true(): bool { From 28e667df8fbf9856ad8789c59cf2250b436dbdb5 Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Thu, 11 Jun 2026 12:16:19 +0100 Subject: [PATCH 4/7] feat: delete plugin options on uninstall Without an uninstall handler, the plugin left its setting behind in the options table after deletion. Guarded on WP_UNINSTALL_PLUGIN so it only runs as part of a genuine uninstall. --- uninstall.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 uninstall.php diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..a6ef191 --- /dev/null +++ b/uninstall.php @@ -0,0 +1,21 @@ + Date: Thu, 11 Jun 2026 12:16:19 +0100 Subject: [PATCH 5/7] build: remove unused cedaro/wp-plugin dependency Nothing in the codebase references the package, so requiring it only added install weight and sent a misleading signal about how the plugin is architected. --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index a7bf0c0..0cdb7ca 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,6 @@ "require": { "php": "^8.4", "brightnucleus/config": "^0.5", - "cedaro/wp-plugin": "^1.0", "composer/installers": "^2" }, "require-dev": { From be78d8b88165b5019183be56e1a0bc66b9c9521a Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Thu, 11 Jun 2026 12:35:17 +0100 Subject: [PATCH 6/7] style: use first-class callable syntax for hook callbacks The Rector php84 set arriving in the code-standards branch converts array callables to the native form, so adopt it here and keep the combined rector dry-run clean. --- src/Plugin.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Plugin.php b/src/Plugin.php index 0b0a080..3ce59c6 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -47,8 +47,8 @@ public function __construct( ConfigInterface $config ) { * @since 0.1.0 */ public function run(): void { - add_action( 'admin_menu', [ $this, 'register_admin_pages' ] ); - add_action( 'admin_init', [ $this, 'register_settings' ] ); + add_action( 'admin_menu', $this->register_admin_pages( ... ) ); + add_action( 'admin_init', $this->register_settings( ... ) ); } /** From c2f3e0211e760b684ef678c7537344df0e55973d Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Thu, 11 Jun 2026 15:38:07 +0100 Subject: [PATCH 7/7] test: cover Plugin registration against mutation testing The testing branch gates Infection at 100% MSI, measured before run() gained real behaviour. Exact-argument expectations and invoked render closures kill every mutant the new registration code introduces, and double as a worked example of testing hooked methods with Brain Monkey. --- tests/Unit/PluginTest.php | 406 +++++++++++++++++++++++++++++++++++++ tests/fixtures/field.php | 20 ++ tests/fixtures/page.php | 16 ++ tests/fixtures/section.php | 16 ++ 4 files changed, 458 insertions(+) create mode 100644 tests/Unit/PluginTest.php create mode 100644 tests/fixtures/field.php create mode 100644 tests/fixtures/page.php create mode 100644 tests/fixtures/section.php diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php new file mode 100644 index 0000000..c30f55a --- /dev/null +++ b/tests/Unit/PluginTest.php @@ -0,0 +1,406 @@ +fixtures = dirname( __DIR__ ) . '/fixtures/'; + + // The field fixture mirrors the real view and escapes its output. + Functions\when( 'esc_html' )->returnArg(); + } + + /** + * Build the test Config consumed by the Plugin. + * + * 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. + * + * @return Config Config to parametrize the Plugin. + */ + 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', + ], + ], + ], + ], + ], + ], + ], + ] + ); + } + + /** + * Capture the output produced by invoking a render closure. + * + * @param Closure $closure Render closure captured from a registration call. + * @param array ...$args Arguments to pass to the closure. + * + * @return string Buffered output. + */ + private function capture( Closure $closure, array ...$args ): string { + ob_start(); + $closure( ...$args ); + + return (string) ob_get_clean(); + } + + /** + * The run() method wires register_admin_pages() to the admin_menu hook. + */ + 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 ); + + return 'register_admin_pages' === $reflection->getName() + && $plugin === $reflection->getClosureThis(); + } + ) + ); + + // The admin_init hook is also added, but is asserted by another test. + Functions\expect( 'add_action' )->with( 'admin_init', Mockery::any() ); + + $plugin->run(); + } + + /** + * The run() method wires register_settings() to the admin_init hook. + */ + public function test_run_hooks_register_settings_onto_admin_init(): void { + $plugin = new Testee( $this->make_config() ); + + Functions\expect( 'add_action' )->with( 'admin_menu', 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(); + } + + /** + * Each submenu page is registered with exact arguments. + */ + public function test_register_admin_pages_passes_exact_arguments(): void { + $plugin = new Testee( $this->make_config() ); + + Functions\expect( 'add_submenu_page' ) + ->once() + ->with( + 'options-general.php', + 'Page Title', + 'Menu Title', + 'manage_options', + 'plugin-slug', + Mockery::type( Closure::class ) + ); + + $plugin->register_admin_pages(); + } + + /** + * The page render closure requires the configured view file. + */ + public function test_register_admin_pages_render_closure_requires_view(): void { + $plugin = new Testee( $this->make_config() ); + + $captured = null; + + Functions\expect( 'add_submenu_page' ) + ->once() + ->with( + Mockery::any(), + Mockery::any(), + Mockery::any(), + Mockery::any(), + Mockery::any(), + Mockery::on( + static function ( $candidate ) use ( &$captured ): bool { + $captured = $candidate; + + return $candidate instanceof Closure; + } + ) + ); + + $plugin->register_admin_pages(); + + static::assertInstanceOf( Closure::class, $captured ); + static::assertSame( 'PAGE_FIXTURE_OUTPUT', $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' ] ); + + Functions\expect( 'register_setting' ) + ->once() + ->with( + 'pluginslug', + 'option1', + Mockery::on( + static fn( $args ): bool => is_array( $args ) + && array_keys( $args ) === [ 'sanitize_callback' ] + && $args['sanitize_callback'] === $sanitize + ) + ); + + Functions\expect( 'add_settings_section' )->andReturnNull(); + Functions\expect( 'add_settings_field' )->andReturnNull(); + + $plugin->register_settings(); + } + + /** + * The sanitize callback wired into the setting is the one from the Config. + */ + 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() ); + + Functions\expect( 'register_setting' )->andReturnNull(); + + Functions\expect( 'add_settings_section' ) + ->once() + ->with( + 'section1', + 'Section Title', + Mockery::type( Closure::class ), + 'pluginslug' + ); + + Functions\expect( 'add_settings_field' )->andReturnNull(); + + $plugin->register_settings(); + } + + /** + * The section render closure requires the configured view file. + */ + public function test_register_settings_section_closure_requires_view(): void { + $plugin = new Testee( $this->make_config() ); + + $captured = null; + + Functions\expect( 'register_setting' )->andReturnNull(); + + Functions\expect( 'add_settings_section' ) + ->once() + ->with( + Mockery::any(), + Mockery::any(), + Mockery::on( + static function ( $candidate ) use ( &$captured ): bool { + $captured = $candidate; + + return $candidate instanceof Closure; + } + ), + Mockery::any() + ); + + Functions\expect( 'add_settings_field' )->andReturnNull(); + + $plugin->register_settings(); + + static::assertInstanceOf( Closure::class, $captured ); + static::assertSame( 'SECTION_FIXTURE_OUTPUT', $this->capture( $captured ) ); + } + + /** + * Each field is registered with exact arguments. + */ + public function test_register_settings_registers_field_with_exact_arguments(): void { + $plugin = new Testee( $this->make_config() ); + + Functions\expect( 'register_setting' )->andReturnNull(); + Functions\expect( 'add_settings_section' )->andReturnNull(); + + Functions\expect( 'add_settings_field' ) + ->once() + ->with( + 'field1', + 'Field Title', + Mockery::type( Closure::class ), + 'pluginslug', + 'section1', + [ + 'label_for' => 'field1', + 'option_name' => 'option1', + ] + ); + + $plugin->register_settings(); + } + + /** + * The field render closure requires the view and receives the $args array. + */ + public function test_register_settings_field_closure_requires_view_with_args(): void { + $plugin = new Testee( $this->make_config() ); + + $captured = null; + + Functions\expect( 'register_setting' )->andReturnNull(); + Functions\expect( 'add_settings_section' )->andReturnNull(); + + Functions\expect( 'add_settings_field' ) + ->once() + ->with( + Mockery::any(), + Mockery::any(), + Mockery::on( + static function ( $candidate ) use ( &$captured ): bool { + $captured = $candidate; + + return $candidate instanceof Closure; + } + ), + Mockery::any(), + Mockery::any(), + Mockery::any() + ); + + $plugin->register_settings(); + + static::assertInstanceOf( Closure::class, $captured ); + + $output = $this->capture( + $captured, + [ + 'label_for' => 'field1', + 'option_name' => 'option1', + ] + ); + + static::assertSame( 'FIELD_FIXTURE_OUTPUT:field1:option1', $output ); + } +} diff --git a/tests/fixtures/field.php b/tests/fixtures/field.php new file mode 100644 index 0000000..c5b77da --- /dev/null +++ b/tests/fixtures/field.php @@ -0,0 +1,20 @@ +