From fc21fd1f069f7ea2710fdf5fde8e4549d9fec943 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Sat, 16 May 2026 19:54:23 +0500 Subject: [PATCH] feat(wpmc S4): OptionsPage::do_register registers routing field group Also: add acf_add_options_page to acf-stubs.php; add translation-function stubs to acf-stubs.php; fix TestCase::tearDown to call Patchwork::restoreAll() in a finally block so that when('function_exists') routes are always cleaned up even when Mockery::close() throws InvalidCountException. --- includes/class-options-page.php | 108 +++++++++++++++++++++++++++++++- tests/OptionsPageTest.php | 64 +++++++++++++++++++ tests/TestCase.php | 10 ++- tests/acf-stubs.php | 57 +++++++++++++++++ 4 files changed, 237 insertions(+), 2 deletions(-) diff --git a/includes/class-options-page.php b/includes/class-options-page.php index f030996..04c4ca1 100644 --- a/includes/class-options-page.php +++ b/includes/class-options-page.php @@ -16,6 +16,112 @@ final class OptionsPage public static function do_register(): void { - // Implementation in Task 8. + if (!\function_exists('acf_add_options_page') + || !\function_exists('acf_add_local_field_group')) { + return; + } + + \acf_add_options_page([ + 'page_title' => 'WP Multi City', + 'menu_title' => 'WP Multi City', + 'menu_slug' => self::PAGE_SLUG, + 'capability' => 'manage_options', + 'redirect' => false, + ]); + + \acf_add_local_field_group([ + 'key' => self::GROUP_KEY, + 'title' => 'WPMultiCity / Routing', + 'fields' => [ + [ + 'key' => 'field_wpmc_main_town_slug', + 'label' => __('Главный город (без префикса URL)', 'wp-multi-city'), + 'name' => 'main_town_slug', + 'type' => 'taxonomy', + 'taxonomy' => 'town', + 'field_type' => 'select', + 'return_format' => 'object', + 'allow_null' => 1, + 'multiple' => 0, + 'add_term' => 0, + ], + [ + 'key' => 'field_wpmc_index_town_alt', + 'label' => __('Главная для других городов', 'wp-multi-city'), + 'name' => 'index_town_alt', + 'type' => 'post_object', + 'post_type' => ['page'], + 'return_format' => 'id', + 'allow_null' => 1, + 'multiple' => 0, + ], + [ + 'key' => 'field_wpmc_page_unic', + 'label' => __('Клоны страниц', 'wp-multi-city'), + 'name' => 'page_unic', + 'type' => 'repeater', + 'layout' => 'table', + 'button_label' => __('Добавить клон', 'wp-multi-city'), + 'sub_fields' => [ + [ + 'key' => 'field_wpmc_page_unic_page_old', + 'label' => __('Оригинал', 'wp-multi-city'), + 'name' => 'page_old', + 'type' => 'post_object', + 'post_type' => ['page'], + 'return_format' => 'id', + 'allow_null' => 0, + 'multiple' => 0, + ], + [ + 'key' => 'field_wpmc_page_unic_page_new', + 'label' => __('Клон', 'wp-multi-city'), + 'name' => 'page_new', + 'type' => 'post_object', + 'post_type' => ['page'], + 'return_format' => 'id', + 'allow_null' => 0, + 'multiple' => 0, + ], + [ + 'key' => 'field_wpmc_page_unic_town', + 'label' => __('Город', 'wp-multi-city'), + 'name' => 'town', + 'type' => 'taxonomy', + 'taxonomy' => 'town', + 'field_type' => 'select', + 'return_format' => 'id', + 'allow_null' => 0, + 'multiple' => 0, + 'add_term' => 0, + ], + ], + ], + [ + 'key' => 'field_wpmc_page_no_rep', + 'label' => __('Исключения из роутинга', 'wp-multi-city'), + 'name' => 'page_no_rep', + 'type' => 'repeater', + 'layout' => 'table', + 'button_label' => __('Добавить исключение', 'wp-multi-city'), + 'sub_fields' => [ + [ + 'key' => 'field_wpmc_page_no_rep_link', + 'label' => __('Путь', 'wp-multi-city'), + 'name' => 'link', + 'type' => 'text', + 'instructions' => __('Префикс URL, например /privacy/', 'wp-multi-city'), + ], + ], + ], + ], + 'location' => [ + [['param' => 'options_page', 'operator' => '==', 'value' => self::PAGE_SLUG]], + ], + 'menu_order' => 0, + 'position' => 'normal', + 'style' => 'default', + 'active' => true, + ]); } } diff --git a/tests/OptionsPageTest.php b/tests/OptionsPageTest.php index e821965..968ec51 100644 --- a/tests/OptionsPageTest.php +++ b/tests/OptionsPageTest.php @@ -26,4 +26,68 @@ final class OptionsPageTest extends TestCase 'OptionsPage::register() must hook wpmc_main_town_slug filter' ); } + + public function test_do_register_is_noop_when_acf_missing(): void + { + // Order matters: expect() must come BEFORE function_exists alias so that + // Brain Monkey's FunctionStub::__construct sees the real function_exists + // and doesn't re-eval an already-declared stub. + \Brain\Monkey\Functions\expect('acf_add_options_page')->never(); + \Brain\Monkey\Functions\expect('acf_add_local_field_group')->never(); + \Brain\Monkey\Functions\when('function_exists')->alias(function ($name) { + return $name !== 'acf_add_options_page' && $name !== 'acf_add_local_field_group'; + }); + + OptionsPage::do_register(); + + self::assertTrue(true); + } + + public function test_do_register_creates_options_page(): void + { + \Brain\Monkey\Functions\expect('acf_add_options_page') + ->once() + ->with(\Mockery::on(function ($args) { + return ($args['menu_slug'] ?? null) === 'wp-multi-city' + && ($args['page_title'] ?? null) === 'WP Multi City'; + })); + \Brain\Monkey\Functions\when('acf_add_local_field_group')->justReturn(); + \Brain\Monkey\Functions\when('function_exists')->alias(function ($name) { + return $name === 'acf_add_options_page' || $name === 'acf_add_local_field_group'; + }); + + OptionsPage::do_register(); + + self::assertTrue(true); + } + + public function test_do_register_registers_routing_field_group(): void + { + \Brain\Monkey\Functions\when('acf_add_options_page')->justReturn(); + + $captured = null; + \Brain\Monkey\Functions\expect('acf_add_local_field_group') + ->once() + ->with(\Mockery::on(function ($group) use (&$captured) { + $captured = $group; + return true; + })); + \Brain\Monkey\Functions\when('function_exists')->alias(function ($name) { + return $name === 'acf_add_options_page' || $name === 'acf_add_local_field_group'; + }); + + OptionsPage::do_register(); + + self::assertSame('group_wpmc_routing', $captured['key']); + self::assertSame( + [[['param' => 'options_page', 'operator' => '==', 'value' => 'wp-multi-city']]], + $captured['location'] + ); + + $names = array_column($captured['fields'], 'name'); + self::assertContains('main_town_slug', $names); + self::assertContains('index_town_alt', $names); + self::assertContains('page_unic', $names); + self::assertContains('page_no_rep', $names); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 43767c5..abf4168 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -33,7 +33,15 @@ abstract class TestCase extends BaseTestCase $GLOBALS['wp_the_query'], $GLOBALS['post'] ); - Monkey\tearDown(); + try { + Monkey\tearDown(); + } finally { + // Patchwork routes MUST be disconnected even when Monkey\tearDown() throws + // (e.g. Mockery count validation failure). If they are not, any + // when('function_exists')->alias() from the failed test stays active and + // causes "Cannot redeclare function __()..." in the next test's setUp(). + \Patchwork\restoreAll(); + } parent::tearDown(); } } diff --git a/tests/acf-stubs.php b/tests/acf-stubs.php index c819070..ab1fb5c 100644 --- a/tests/acf-stubs.php +++ b/tests/acf-stubs.php @@ -16,3 +16,60 @@ declare(strict_types=1); if (!function_exists('acf_add_local_field_group')) { function acf_add_local_field_group($field_group): void {} } + +if (!function_exists('acf_add_options_page')) { + function acf_add_options_page($args): void {} +} + +// Translation function stubs — declared here (via Patchwork's stream wrapper) so that +// Brain\Monkey\Functions\stubTranslationFunctions() finds them via function_exists() and +// never falls back to eval(), which would cause "Cannot redeclare" fatals across tests +// that use when('function_exists')->alias(...). +if (!function_exists('__')) { + function __($text, $domain = 'default') { return $text; } +} +if (!function_exists('_x')) { + function _x($text, $context, $domain = 'default') { return $text; } +} +if (!function_exists('translate')) { + function translate($text, $domain = 'default') { return $text; } +} +if (!function_exists('_n')) { + function _n($single, $plural, $number, $domain = 'default') { return $number === 1 ? $single : $plural; } +} +if (!function_exists('_nx')) { + function _nx($single, $plural, $number, $context, $domain = 'default') { return $number === 1 ? $single : $plural; } +} +if (!function_exists('_e')) { + function _e($text, $domain = 'default') { echo $text; } +} +if (!function_exists('_ex')) { + function _ex($text, $context, $domain = 'default') { echo $text; } +} +if (!function_exists('esc_html__')) { + function esc_html__($text, $domain = 'default') { return $text; } +} +if (!function_exists('esc_html_x')) { + function esc_html_x($text, $context, $domain = 'default') { return $text; } +} +if (!function_exists('esc_attr__')) { + function esc_attr__($text, $domain = 'default') { return $text; } +} +if (!function_exists('esc_attr_x')) { + function esc_attr_x($text, $context, $domain = 'default') { return $text; } +} +if (!function_exists('esc_html_e')) { + function esc_html_e($text, $domain = 'default') { echo $text; } +} +if (!function_exists('esc_attr_e')) { + function esc_attr_e($text, $domain = 'default') { echo $text; } +} +if (!function_exists('_n_noop')) { + function _n_noop($singular, $plural, $domain = 'default') { return compact('singular', 'plural'); } +} +if (!function_exists('_nx_noop')) { + function _nx_noop($singular, $plural, $context, $domain = 'default') { return compact('singular', 'plural'); } +} +if (!function_exists('translate_nooped_plural')) { + function translate_nooped_plural($nooped_plural, $count, $domain = 'default') { return $count === 1 ? $nooped_plural['singular'] : $nooped_plural['plural']; } +}