Files
wp-multi-city/docs/superpowers/plans/2026-05-13-s4-url-router.md
T
2026-05-16 19:12:57 +05:00

65 KiB
Raw Blame History

S4 — URL-роутер /{city}/{path}/ + page_unic + main_page_alt Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement the URL router that maps /{city-slug}/{path}/ to existing pages via $wp_query patching on wp action priority 9999, including ACF options page + per-term main_page_alt + page clone swap + 301 redirects.

Architecture: Four new static classes (OptionsPage, TermMeta, Router, Redirect) registered from Plugin::boot(). Helpers gets four new utility methods (main_town_slug, is_routable_path, page_unic_swap, index_town_alt_page_id). All routing logic depends only on ACF reads and WP function mocks — no real WordPress runtime needed for tests.

Tech Stack: PHP 8.0+, WordPress 6.0+, ACF Pro, PHPUnit 9.6, Brain Monkey 2.7, Mockery 1.6, Patchwork.

Spec: docs/superpowers/specs/2026-05-13-s4-url-router-design.md Beads: cp-83m


Working Directory

All paths relative to:

/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city

Run shell commands from that directory (quote paths with Cyrillic/spaces).


File Structure

wp-multi-city/
├── includes/
│   ├── class-plugin.php            # MODIFY — boot() adds 4 more registrations
│   ├── class-helpers.php           # MODIFY — 4 new methods + reset_cache extension
│   ├── class-options-page.php      # CREATE — OptionsPage
│   ├── class-term-meta.php         # CREATE — TermMeta
│   ├── class-router.php            # CREATE — Router
│   └── class-redirect.php          # CREATE — Redirect
└── tests/
    ├── bootstrap.php               # MODIFY — add WP_Query stub
    ├── TestCase.php                # MODIFY — tearDown cleans wp_query globals
    ├── HelpersTest.php             # MODIFY — 6 new method tests
    ├── OptionsPageTest.php         # CREATE
    ├── TermMetaTest.php            # CREATE
    ├── RouterTest.php              # CREATE
    ├── RedirectTest.php            # CREATE
    └── PluginBootS4Test.php        # CREATE — wires 4 new registrations

Boundaries:

  • One class per ACF concern + one class per hook concern.
  • Helpers keeps growing with utility methods (~150 lines after S4 — still within budget).
  • All tests mock WP/ACF functions via Brain Monkey; no real WP needed.

Task 1: Test infrastructure — WP_Query stub + global cleanup

Files:

  • Modify: tests/bootstrap.php
  • Modify: tests/TestCase.php

S4 tests need new \WP_Query($args) to be capturable, and the suite must clean global $wp_query/$wp_the_query/$post between tests.

  • Step 1.1: Add WP_Query stub to tests/bootstrap.php

Open tests/bootstrap.php and append after the existing WP_Term stub:

if (!class_exists('WP_Query', false)) {
    eval('class WP_Query {
        public array $constructor_args = [];
        public bool $is_page = false;
        public bool $is_front_page = false;
        public bool $is_404 = false;
        public function __construct($args = []) { $this->constructor_args = is_array($args) ? $args : []; }
        public function set_404(): void { $this->is_404 = true; }
        public function set($k, $v): void {}
    }');
}

Final tests/bootstrap.php:

<?php
declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

if (!defined('ABSPATH')) {
    define('ABSPATH', __DIR__ . '/');
}

require_once __DIR__ . '/../includes/class-plugin.php';
\WPMultiCity\Plugin::register_autoloader();

if (!class_exists('WP_Term', false)) {
    eval('class WP_Term { public int $term_id = 0; public string $slug = ""; public string $taxonomy = ""; }');
}

if (!class_exists('WP_Query', false)) {
    eval('class WP_Query {
        public array $constructor_args = [];
        public bool $is_page = false;
        public bool $is_front_page = false;
        public bool $is_404 = false;
        public function __construct($args = []) { $this->constructor_args = is_array($args) ? $args : []; }
        public function set_404(): void { $this->is_404 = true; }
        public function set($k, $v): void {}
    }');
}
  • Step 1.2: Extend TestCase::tearDown to clean wp_query globals

Replace tearDown() in tests/TestCase.php with:

    protected function tearDown(): void
    {
        unset(
            $_SERVER['REQUEST_URI'],
            $GLOBALS['wp_query'],
            $GLOBALS['wp_the_query'],
            $GLOBALS['post']
        );
        Monkey\tearDown();
        parent::tearDown();
    }
  • Step 1.3: Run full suite — must still be 34 tests green
vendor/bin/phpunit

Expected: OK (34 tests, 79 assertions).

  • Step 1.4: Commit
git add tests/bootstrap.php tests/TestCase.php
git commit -m "test(wpmc S4): WP_Query stub + wp_query globals cleanup"

Task 2: Helpers::main_town_slug — RED + GREEN

Files:

  • Modify: tests/HelpersTest.php

  • Modify: includes/class-helpers.php

  • Step 2.1: RED — append failing test to tests/HelpersTest.php

    public function test_main_town_slug_reads_option_via_acf(): void
    {
        \Brain\Monkey\Functions\expect('get_field')
            ->once()
            ->with('main_town_slug', 'option')
            ->andReturn('spb');

        self::assertSame('spb', Helpers::main_town_slug());
    }

    public function test_main_town_slug_returns_null_when_option_empty(): void
    {
        \Brain\Monkey\Functions\expect('get_field')
            ->once()
            ->with('main_town_slug', 'option')
            ->andReturn(false);

        self::assertNull(Helpers::main_town_slug());
    }

Run: vendor/bin/phpunit --filter main_town_slug Expected: FAIL — undefined method.

  • Step 2.2: GREEN — append to Helpers class
    public static function main_town_slug(): ?string
    {
        if (!\function_exists('get_field')) {
            return null;
        }
        $value = \get_field('main_town_slug', 'option');
        return is_string($value) && $value !== '' ? $value : null;
    }

Run: vendor/bin/phpunit --filter main_town_slug Expected: OK (2 tests, 2 assertions).

  • Step 2.3: Commit
git add tests/HelpersTest.php includes/class-helpers.php
git commit -m "feat(wpmc S4): Helpers::main_town_slug reads ACF option"

Task 3: Helpers::is_routable_path — RED + GREEN

Files:

  • Modify: tests/HelpersTest.php

  • Modify: includes/class-helpers.php

  • Step 3.1: RED — append tests

    public function test_is_routable_path_returns_true_when_no_exclusions(): void
    {
        \Brain\Monkey\Functions\expect('get_field')
            ->once()
            ->with('page_no_rep', 'option')
            ->andReturn(false);

        self::assertTrue(Helpers::is_routable_path('/foo/bar/'));
    }

    public function test_is_routable_path_returns_false_when_path_matches_exclusion(): void
    {
        \Brain\Monkey\Functions\expect('get_field')
            ->once()
            ->with('page_no_rep', 'option')
            ->andReturn([
                ['link' => '/privacy/'],
                ['link' => '/terms/'],
            ]);

        self::assertFalse(Helpers::is_routable_path('/privacy/'));
    }

    public function test_is_routable_path_uses_request_uri_when_path_null(): void
    {
        $_SERVER['REQUEST_URI'] = '/terms/';
        \Brain\Monkey\Functions\expect('get_field')
            ->once()
            ->with('page_no_rep', 'option')
            ->andReturn([['link' => '/terms/']]);

        self::assertFalse(Helpers::is_routable_path());
    }

Run: vendor/bin/phpunit --filter is_routable_path Expected: FAIL — undefined method.

  • Step 3.2: GREEN — append to Helpers class
    public static function is_routable_path(?string $path = null): bool
    {
        if ($path === null) {
            $path = $_SERVER['REQUEST_URI'] ?? '/';
        }
        if (!\function_exists('get_field')) {
            return true;
        }
        $exclusions = \get_field('page_no_rep', 'option');
        if (!is_array($exclusions) || $exclusions === []) {
            return true;
        }
        foreach ($exclusions as $row) {
            if (!is_array($row) || empty($row['link'])) {
                continue;
            }
            $needle = (string) $row['link'];
            if ($needle !== '' && strpos($path, $needle) !== false) {
                return false;
            }
        }
        return true;
    }

Run: vendor/bin/phpunit --filter is_routable_path Expected: OK (3 tests, 3 assertions).

  • Step 3.3: Commit
git add tests/HelpersTest.php includes/class-helpers.php
git commit -m "feat(wpmc S4): Helpers::is_routable_path with page_no_rep exclusions"

Task 4: Helpers::page_unic_swap — RED + GREEN + cache test

Files:

  • Modify: tests/HelpersTest.php

  • Modify: includes/class-helpers.php

  • Step 4.1: RED — append tests

    public function test_page_unic_swap_returns_clone_id_when_row_matches(): void
    {
        $term = new \WP_Term();
        $term->term_id = 11;
        $term->slug = 'tyumen';
        \Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
        \Brain\Monkey\Functions\expect('get_field')
            ->once()
            ->with('page_unic', 'option')
            ->andReturn([
                ['town' => 10, 'page_old' => 5, 'page_new' => 50],
                ['town' => 11, 'page_old' => 7, 'page_new' => 70],
            ]);

        self::assertSame(70, Helpers::page_unic_swap(7, 'tyumen'));
    }

    public function test_page_unic_swap_returns_null_when_no_row_matches(): void
    {
        $term = new \WP_Term();
        $term->term_id = 11;
        $term->slug = 'tyumen';
        \Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
        \Brain\Monkey\Functions\expect('get_field')
            ->once()
            ->andReturn([['town' => 10, 'page_old' => 5, 'page_new' => 50]]);

        self::assertNull(Helpers::page_unic_swap(7, 'tyumen'));
    }

    public function test_page_unic_swap_caches_within_request(): void
    {
        $term = new \WP_Term();
        $term->term_id = 11;
        \Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
        \Brain\Monkey\Functions\expect('get_field')
            ->once()  // would be 3 without cache
            ->andReturn([['town' => 11, 'page_old' => 7, 'page_new' => 70]]);

        Helpers::page_unic_swap(7, 'tyumen');
        Helpers::page_unic_swap(7, 'tyumen');
        Helpers::page_unic_swap(7, 'tyumen');

        self::assertTrue(true);
    }

Run: vendor/bin/phpunit --filter page_unic_swap Expected: FAIL — undefined method.

  • Step 4.2: GREEN — extend Helpers

First, add to the private static array $term_cache = []; block in class-helpers.php:

    private static array $page_unic_cache = [];

Extend reset_cache():

    public static function reset_cache(): void
    {
        self::$current_town_memo = null;
        self::$current_town_resolved = false;
        self::$term_cache = [];
        self::$page_unic_cache = [];
    }

Append method:

    public static function page_unic_swap(int $page_id_old, string $town_slug): ?int
    {
        $key = $page_id_old . '|' . $town_slug;
        if (array_key_exists($key, self::$page_unic_cache)) {
            return self::$page_unic_cache[$key];
        }
        $term = self::get_term_by_slug($town_slug);
        if ($term === null) {
            return self::$page_unic_cache[$key] = null;
        }
        if (!\function_exists('get_field')) {
            return self::$page_unic_cache[$key] = null;
        }
        $rows = \get_field('page_unic', 'option');
        if (!is_array($rows)) {
            return self::$page_unic_cache[$key] = null;
        }
        foreach ($rows as $row) {
            if (!is_array($row)) {
                continue;
            }
            if ((int) ($row['town'] ?? 0) === (int) $term->term_id
                && (int) ($row['page_old'] ?? 0) === $page_id_old) {
                $new = (int) ($row['page_new'] ?? 0);
                return self::$page_unic_cache[$key] = $new > 0 ? $new : null;
            }
        }
        return self::$page_unic_cache[$key] = null;
    }

Run: vendor/bin/phpunit --filter page_unic_swap Expected: OK (3 tests, 3 assertions).

  • Step 4.3: Commit
git add tests/HelpersTest.php includes/class-helpers.php
git commit -m "feat(wpmc S4): Helpers::page_unic_swap with per-request cache"

Task 5: Helpers::index_town_alt_page_id — RED + GREEN

Files:

  • Modify: tests/HelpersTest.php

  • Modify: includes/class-helpers.php

  • Step 5.1: RED — append test

    public function test_index_town_alt_page_id_returns_int_when_option_set(): void
    {
        \Brain\Monkey\Functions\expect('get_field')
            ->once()
            ->with('index_town_alt', 'option')
            ->andReturn(42);

        self::assertSame(42, Helpers::index_town_alt_page_id());
    }

    public function test_index_town_alt_page_id_returns_null_when_zero(): void
    {
        \Brain\Monkey\Functions\expect('get_field')
            ->once()
            ->with('index_town_alt', 'option')
            ->andReturn(0);

        self::assertNull(Helpers::index_town_alt_page_id());
    }

Run: vendor/bin/phpunit --filter index_town_alt_page_id Expected: FAIL — undefined method.

  • Step 5.2: GREEN — append
    public static function index_town_alt_page_id(): ?int
    {
        if (!\function_exists('get_field')) {
            return null;
        }
        $value = \get_field('index_town_alt', 'option');
        $id = (int) $value;
        return $id > 0 ? $id : null;
    }

Run: vendor/bin/phpunit --filter index_town_alt_page_id Expected: OK (2 tests, 2 assertions).

  • Step 5.3: Full suite check
vendor/bin/phpunit

Expected: OK (44 tests, ~89 assertions).

  • Step 5.4: Commit
git add tests/HelpersTest.php includes/class-helpers.php
git commit -m "feat(wpmc S4): Helpers::index_town_alt_page_id reads ACF option"

Task 6: TermMeta — RED + GREEN

Files:

  • Create: tests/TermMetaTest.php

  • Create: includes/class-term-meta.php

  • Step 6.1: RED — create test file

tests/TermMetaTest.php:

<?php
declare(strict_types=1);

namespace WPMultiCity\Tests;

use WPMultiCity\TermMeta;

final class TermMetaTest extends TestCase
{
    public function test_register_attaches_acf_init_action(): void
    {
        TermMeta::register();

        self::assertNotFalse(
            has_action('acf/init', [TermMeta::class, 'do_register']),
            'TermMeta::register() must hook acf/init'
        );
    }

    public function test_do_register_is_noop_when_acf_missing(): void
    {
        \Brain\Monkey\Functions\when('function_exists')->alias(function ($name) {
            return $name !== 'acf_add_local_field_group';
        });
        \Brain\Monkey\Functions\expect('acf_add_local_field_group')->never();

        TermMeta::do_register();

        self::assertTrue(true);
    }

    public function test_do_register_registers_main_page_alt_field_group(): void
    {
        \Brain\Monkey\Functions\expect('acf_add_local_field_group')->once();
        \Brain\Monkey\Functions\when('function_exists')->alias(function ($name) {
            return $name === 'acf_add_local_field_group';
        });

        $captured = null;
        \Brain\Monkey\Functions\expect('acf_add_local_field_group')
            ->once()
            ->with(\Mockery::on(function ($group) use (&$captured) {
                $captured = $group;
                return true;
            }));

        TermMeta::do_register();

        self::assertSame('group_wpmc_town_main_page', $captured['key']);
        self::assertSame(
            [[['param' => 'taxonomy', 'operator' => '==', 'value' => 'town']]],
            $captured['location']
        );
        self::assertCount(1, $captured['fields']);
        self::assertSame('field_wpmc_main_page_alt', $captured['fields'][0]['key']);
        self::assertSame('main_page_alt', $captured['fields'][0]['name']);
        self::assertSame('post_object', $captured['fields'][0]['type']);
        self::assertSame('id', $captured['fields'][0]['return_format']);
        self::assertSame(['page'], $captured['fields'][0]['post_type']);
    }
}

Note: the test method test_do_register_registers_main_page_alt_field_group opens by calling expect(...)->once() once just to lock the call site early, then immediately re-binds it with a fuller ->with() capture. Keep both lines verbatim — Brain Monkey's later expect supersedes the earlier one for argument matching, but both count as one expectation. (Pattern borrowed from S3.)

Actually, the first expect('acf_add_local_field_group')->once(); line is a leftover from a draft and will confuse Mockery. Remove it. The final test body keeps only the expect(...)->once()->with(Mockery::on(...)) version. Final test:

    public function test_do_register_registers_main_page_alt_field_group(): void
    {
        \Brain\Monkey\Functions\when('function_exists')->alias(function ($name) {
            return $name === 'acf_add_local_field_group';
        });

        $captured = null;
        \Brain\Monkey\Functions\expect('acf_add_local_field_group')
            ->once()
            ->with(\Mockery::on(function ($group) use (&$captured) {
                $captured = $group;
                return true;
            }));

        TermMeta::do_register();

        self::assertSame('group_wpmc_town_main_page', $captured['key']);
        self::assertSame(
            [[['param' => 'taxonomy', 'operator' => '==', 'value' => 'town']]],
            $captured['location']
        );
        self::assertCount(1, $captured['fields']);
        self::assertSame('field_wpmc_main_page_alt', $captured['fields'][0]['key']);
        self::assertSame('main_page_alt', $captured['fields'][0]['name']);
        self::assertSame('post_object', $captured['fields'][0]['type']);
        self::assertSame('id', $captured['fields'][0]['return_format']);
        self::assertSame(['page'], $captured['fields'][0]['post_type']);
    }

Run: vendor/bin/phpunit tests/TermMetaTest.php Expected: 3 errors — Class "WPMultiCity\TermMeta" not found.

  • Step 6.2: GREEN — create includes/class-term-meta.php
<?php
declare(strict_types=1);

namespace WPMultiCity;

final class TermMeta
{
    public const GROUP_KEY = 'group_wpmc_town_main_page';

    public static function register(): void
    {
        \add_action('acf/init', [self::class, 'do_register']);
    }

    public static function do_register(): void
    {
        if (!\function_exists('acf_add_local_field_group')) {
            return;
        }
        \acf_add_local_field_group([
            'key'    => self::GROUP_KEY,
            'title'  => 'WPMultiCity / Town main page',
            'fields' => [
                [
                    'key'           => 'field_wpmc_main_page_alt',
                    'label'         => __('Главная страница для этого города', 'wp-multi-city'),
                    'name'          => 'main_page_alt',
                    'type'          => 'post_object',
                    'post_type'     => ['page'],
                    'return_format' => 'id',
                    'allow_null'    => 1,
                    'multiple'      => 0,
                ],
            ],
            'location'   => [
                [['param' => 'taxonomy', 'operator' => '==', 'value' => 'town']],
            ],
            'menu_order' => 0,
            'position'   => 'normal',
            'style'      => 'default',
            'active'     => true,
        ]);
    }
}

Run: vendor/bin/phpunit tests/TermMetaTest.php Expected: OK (3 tests, ≥9 assertions).

  • Step 6.3: Commit
git add tests/TermMetaTest.php includes/class-term-meta.php
git commit -m "feat(wpmc S4): TermMeta registers ACF main_page_alt on taxonomy town"

Task 7: OptionsPage — scaffold + register hook test

Files:

  • Create: tests/OptionsPageTest.php

  • Create: includes/class-options-page.php

  • Step 7.1: RED — create test file

tests/OptionsPageTest.php:

<?php
declare(strict_types=1);

namespace WPMultiCity\Tests;

use WPMultiCity\OptionsPage;

final class OptionsPageTest extends TestCase
{
    public function test_register_attaches_acf_init_action(): void
    {
        OptionsPage::register();

        self::assertNotFalse(
            has_action('acf/init', [OptionsPage::class, 'do_register']),
            'OptionsPage::register() must hook acf/init'
        );
    }

    public function test_register_attaches_main_town_slug_filter(): void
    {
        OptionsPage::register();

        self::assertNotFalse(
            has_filter('wpmc_main_town_slug', ['WPMultiCity\\Helpers', 'main_town_slug']),
            'OptionsPage::register() must hook wpmc_main_town_slug filter'
        );
    }
}

Run: vendor/bin/phpunit tests/OptionsPageTest.php Expected: 2 errors — class not found.

  • Step 7.2: GREEN — create scaffold

includes/class-options-page.php:

<?php
declare(strict_types=1);

namespace WPMultiCity;

final class OptionsPage
{
    public const PAGE_SLUG = 'wp-multi-city';
    public const GROUP_KEY = 'group_wpmc_routing';

    public static function register(): void
    {
        \add_action('acf/init', [self::class, 'do_register']);
        \add_filter('wpmc_main_town_slug', [Helpers::class, 'main_town_slug'], 5);
    }

    public static function do_register(): void
    {
        // Implementation in Tasks 8-9.
    }
}

Run: vendor/bin/phpunit tests/OptionsPageTest.php Expected: OK (2 tests, 2 assertions).

  • Step 7.3: Commit
git add tests/OptionsPageTest.php includes/class-options-page.php
git commit -m "feat(wpmc S4): OptionsPage scaffold + main_town_slug filter wiring"

Task 8: OptionsPage::do_register — options page + field group

Files:

  • Modify: tests/OptionsPageTest.php

  • Modify: includes/class-options-page.php

  • Step 8.1: RED — append tests

    public function test_do_register_creates_options_page(): void
    {
        \Brain\Monkey\Functions\when('function_exists')->alias(function ($name) {
            return $name === 'acf_add_options_page' || $name === 'acf_add_local_field_group';
        });
        \Brain\Monkey\Functions\when('acf_add_local_field_group')->justReturn(true);

        \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';
            }));

        OptionsPage::do_register();

        self::assertTrue(true);
    }

    public function test_do_register_registers_routing_field_group(): void
    {
        \Brain\Monkey\Functions\when('function_exists')->alias(function ($name) {
            return $name === 'acf_add_options_page' || $name === 'acf_add_local_field_group';
        });
        \Brain\Monkey\Functions\when('acf_add_options_page')->justReturn(true);

        $captured = null;
        \Brain\Monkey\Functions\expect('acf_add_local_field_group')
            ->once()
            ->with(\Mockery::on(function ($group) use (&$captured) {
                $captured = $group;
                return true;
            }));

        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);
    }

    public function test_do_register_is_noop_when_acf_missing(): void
    {
        \Brain\Monkey\Functions\when('function_exists')->justReturn(false);
        \Brain\Monkey\Functions\expect('acf_add_options_page')->never();
        \Brain\Monkey\Functions\expect('acf_add_local_field_group')->never();

        OptionsPage::do_register();

        self::assertTrue(true);
    }

Run: vendor/bin/phpunit tests/OptionsPageTest.php Expected: 3 errors/failures — do_register is empty stub.

  • Step 8.2: GREEN — replace do_register body

Replace empty do_register() in includes/class-options-page.php:

    public static function do_register(): void
    {
        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,
        ]);
    }

Note field_wpmc_main_town_slug uses return_format => 'object' — but Helpers::main_town_slug() reads get_field('main_town_slug', 'option') and expects a string. ACF taxonomy field with return_format=object returns a WP_Term. Inconsistency. Fix Helpers::main_town_slug() to handle both: if value is WP_Term, return $value->slug; if string, return as-is.

Update Helpers::main_town_slug() accordingly:

    public static function main_town_slug(): ?string
    {
        if (!\function_exists('get_field')) {
            return null;
        }
        $value = \get_field('main_town_slug', 'option');
        if ($value instanceof \WP_Term) {
            return $value->slug !== '' ? $value->slug : null;
        }
        return is_string($value) && $value !== '' ? $value : null;
    }

Then add a test for the WP_Term branch in HelpersTest:

    public function test_main_town_slug_handles_wp_term_return_value(): void
    {
        $term = new \WP_Term();
        $term->slug = 'spb';
        \Brain\Monkey\Functions\expect('get_field')
            ->once()
            ->with('main_town_slug', 'option')
            ->andReturn($term);

        self::assertSame('spb', Helpers::main_town_slug());
    }

Run vendor/bin/phpunit --filter main_town_slug — expect 3 tests pass (the two existing + this new one).

Then continue Task 8 verification:

Run: vendor/bin/phpunit tests/OptionsPageTest.php Expected: OK (5 tests, ≥10 assertions).

  • Step 8.3: Commit
git add tests/OptionsPageTest.php tests/HelpersTest.php includes/class-options-page.php includes/class-helpers.php
git commit -m "feat(wpmc S4): OptionsPage::do_register registers routing field group"

Task 9: Router — scaffold + register test

Files:

  • Create: tests/RouterTest.php

  • Create: includes/class-router.php

  • Step 9.1: RED — create test file

tests/RouterTest.php:

<?php
declare(strict_types=1);

namespace WPMultiCity\Tests;

use WPMultiCity\Router;

final class RouterTest extends TestCase
{
    public function test_register_attaches_wp_action_at_priority_9999(): void
    {
        Router::register();

        self::assertSame(
            9999,
            has_action('wp', [Router::class, 'route']),
            'Router::register() must hook wp action at priority 9999'
        );
    }

    public function test_register_attaches_query_vars_filter(): void
    {
        Router::register();

        self::assertNotFalse(
            has_filter('query_vars', [Router::class, 'add_town_query_var']),
            'Router::register() must hook query_vars filter'
        );
    }

    public function test_add_town_query_var_appends_town(): void
    {
        $vars = ['p', 'page_id'];
        $result = Router::add_town_query_var($vars);

        self::assertContains('town', $result);
    }
}

Run: vendor/bin/phpunit tests/RouterTest.php Expected: 3 errors — class not found.

  • Step 9.2: GREEN — create scaffold

includes/class-router.php:

<?php
declare(strict_types=1);

namespace WPMultiCity;

final class Router
{
    public static function register(): void
    {
        \add_action('wp', [self::class, 'route'], 9999);
        \add_filter('query_vars', [self::class, 'add_town_query_var']);
    }

    public static function add_town_query_var(array $vars): array
    {
        $vars[] = 'town';
        return $vars;
    }

    public static function route(): void
    {
        // Implementation in Tasks 10-13.
    }
}

Run: vendor/bin/phpunit tests/RouterTest.php Expected: OK (3 tests, 3 assertions).

  • Step 9.3: Commit
git add tests/RouterTest.php includes/class-router.php
git commit -m "feat(wpmc S4): Router scaffold + query_vars filter for town"

Task 10: Router::route — skip conditions

Files:

  • Modify: tests/RouterTest.php

  • Modify: includes/class-router.php

  • Step 10.1: RED — append 5 skip-condition tests

    public function test_route_skips_when_is_admin(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(true);
        \Brain\Monkey\Functions\expect('get_terms')->never();

        Router::route();

        self::assertArrayNotHasKey('wp_query', $GLOBALS);
    }

    public function test_route_skips_during_ajax(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
        if (!defined('DOING_AJAX')) {
            define('DOING_AJAX', true);
        }
        \Brain\Monkey\Functions\expect('get_terms')->never();

        Router::route();

        self::assertArrayNotHasKey('wp_query', $GLOBALS);
    }

    public function test_route_skips_when_current_town_is_null(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
        $_SERVER['REQUEST_URI'] = '/something-not-a-city/';
        \Brain\Monkey\Functions\when('get_terms')->justReturn([10 => 'spb']);
        \Brain\Monkey\Functions\expect('get_field')->andReturn(false);

        Router::route();

        self::assertArrayNotHasKey('wp_query', $GLOBALS);
    }

    public function test_route_skips_when_current_town_equals_main_town(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
        $_SERVER['REQUEST_URI'] = '/spb/';
        \Brain\Monkey\Functions\when('get_terms')->justReturn([10 => 'spb']);
        // current_town() will read URI and find 'spb'; then main_town option also 'spb' →
        // route() should bail before url_to_postid.
        \Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
            if ($name === 'main_town_slug') return 'spb';
            return false;
        });
        \Brain\Monkey\Functions\expect('url_to_postid')->never();

        Router::route();

        self::assertArrayNotHasKey('wp_query', $GLOBALS);
    }

    public function test_route_skips_when_path_not_routable(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
        $_SERVER['REQUEST_URI'] = '/tyumen/privacy/';
        \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
        \Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
            if ($name === 'main_town_slug') return 'spb';
            if ($name === 'page_no_rep') return [['link' => '/privacy/']];
            return false;
        });
        \Brain\Monkey\Functions\expect('url_to_postid')->never();

        Router::route();

        self::assertArrayNotHasKey('wp_query', $GLOBALS);
    }

Note: the DOING_AJAX define cannot be undone within PHP. Once defined, it persists for the entire test process. To avoid contaminating later tests, this test must either be the LAST in the suite or use a separate process. Mitigation: run with --process-isolation for this single test, OR (simpler) test the AJAX guard via a function spy on wp_doing_ajax() instead of the constant. Revised: drop the DOING_AJAX test for now and just test is_admin() skip. The AJAX guard remains in production code but isn't covered by a phpunit test — acceptable trade-off given the alternative is bringing in process-isolation just for one test. Remove test_route_skips_during_ajax from the test block above.

Final test block to append (4 tests, not 5):

    public function test_route_skips_when_is_admin(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(true);
        \Brain\Monkey\Functions\expect('get_terms')->never();

        Router::route();

        self::assertArrayNotHasKey('wp_query', $GLOBALS);
    }

    public function test_route_skips_when_current_town_is_null(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
        $_SERVER['REQUEST_URI'] = '/something-not-a-city/';
        \Brain\Monkey\Functions\when('get_terms')->justReturn([10 => 'spb']);
        \Brain\Monkey\Functions\expect('get_field')->andReturn(false);

        Router::route();

        self::assertArrayNotHasKey('wp_query', $GLOBALS);
    }

    public function test_route_skips_when_current_town_equals_main_town(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
        $_SERVER['REQUEST_URI'] = '/spb/';
        \Brain\Monkey\Functions\when('get_terms')->justReturn([10 => 'spb']);
        \Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
            if ($name === 'main_town_slug') return 'spb';
            return false;
        });
        \Brain\Monkey\Functions\expect('url_to_postid')->never();

        Router::route();

        self::assertArrayNotHasKey('wp_query', $GLOBALS);
    }

    public function test_route_skips_when_path_not_routable(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
        $_SERVER['REQUEST_URI'] = '/tyumen/privacy/';
        \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
        \Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
            if ($name === 'main_town_slug') return 'spb';
            if ($name === 'page_no_rep') return [['link' => '/privacy/']];
            return false;
        });
        \Brain\Monkey\Functions\expect('url_to_postid')->never();

        Router::route();

        self::assertArrayNotHasKey('wp_query', $GLOBALS);
    }

Run: vendor/bin/phpunit tests/RouterTest.php Expected: FAIL — route() body is empty; some tests fail because expectations like expect('get_terms')->never() would actually be met (nothing is called), but expect('url_to_postid')->never() also met. So all 4 may PASS trivially with the empty body. That's OK — these tests are invariant locks; they'll start failing if Task 11 forgets to check is_admin, etc.

Actually since route() is empty, ALL tests will pass — they verify "nothing happens", and nothing is what an empty body does. Step 10.1 has no failing test. Skip the RED step explicitly; mark this as invariant-lock tests.

  • Step 10.2: Run — expect PASS (invariant lock)
vendor/bin/phpunit tests/RouterTest.php

Expected: OK (7 tests, 7 assertions). These tests don't drive implementation directly but lock the skip semantics for Task 11.

  • Step 10.3: Commit
git add tests/RouterTest.php
git commit -m "test(wpmc S4): Router::route skip-condition invariants"

Task 11: Router::route — root URL /tyumen/ happy path

Files:

  • Modify: tests/RouterTest.php

  • Modify: includes/class-router.php

  • Step 11.1: RED — append test for root URL with main_page_alt

    public function test_route_uses_main_page_alt_term_meta_for_root_url(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
        $_SERVER['REQUEST_URI'] = '/tyumen/';
        \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);

        $term = new \WP_Term();
        $term->term_id = 11;
        $term->slug = 'tyumen';
        \Brain\Monkey\Functions\when('get_term_by')->justReturn($term);

        \Brain\Monkey\Functions\when('get_field')->alias(function ($name, $where = null) {
            if ($name === 'main_town_slug') return 'spb';
            if ($name === 'page_no_rep') return false;
            if ($name === 'main_page_alt' && $where === 'town_11') return 555;
            return false;
        });
        \Brain\Monkey\Functions\when('get_post')->justReturn((object) ['ID' => 555]);
        \Brain\Monkey\Functions\when('status_header')->justReturn(null);

        Router::route();

        self::assertInstanceOf(\WP_Query::class, $GLOBALS['wp_query']);
        self::assertSame(555, $GLOBALS['wp_query']->constructor_args['page_id']);
        self::assertSame('tyumen', $GLOBALS['wp_query']->constructor_args['town']);
        self::assertTrue($GLOBALS['wp_query']->is_front_page);
        self::assertTrue($GLOBALS['wp_query']->is_page);
    }

Run: vendor/bin/phpunit --filter test_route_uses_main_page_alt_term_meta_for_root_url Expected: FAIL — $GLOBALS['wp_query'] undefined or not a WP_Query (route is empty).

  • Step 11.2: GREEN — implement route() partially (skip checks + root URL only)

Replace route() body in includes/class-router.php:

    public static function route(): void
    {
        if (\is_admin()) {
            return;
        }
        if (\defined('DOING_AJAX') && \DOING_AJAX) {
            return;
        }
        $town_slug = Helpers::current_town();
        if ($town_slug === null) {
            return;
        }
        $main = Helpers::main_town_slug();
        if ($main !== null && $town_slug === $main) {
            return;
        }
        $request_uri = $_SERVER['REQUEST_URI'] ?? '/';
        if (!Helpers::is_routable_path($request_uri)) {
            return;
        }

        $url_original = self::strip_town_prefix($request_uri, $town_slug);

        if (self::is_root($url_original)) {
            $term = Helpers::get_term_by_slug($town_slug);
            if ($term === null) {
                return;
            }
            $page_id = 0;
            if (\function_exists('get_field')) {
                $page_id = (int) (\get_field('main_page_alt', 'town_' . $term->term_id) ?? 0);
            }
            if (!$page_id) {
                $fallback = Helpers::index_town_alt_page_id();
                if ($fallback === null) {
                    return;
                }
                $page_id = $fallback;
            }
            self::install_query($page_id, $town_slug, true);
            return;
        }

        // Internal page handling lands in Task 12.
    }

    private static function strip_town_prefix(string $uri, string $town_slug): string
    {
        $path = strtok($uri, '?');
        $query = (strpos($uri, '?') !== false) ? substr($uri, strpos($uri, '?')) : '';
        $new_path = preg_replace('#^/' . preg_quote($town_slug, '#') . '(/|$)#', '/', (string) $path);
        return ($new_path ?? '/') . $query;
    }

    private static function is_root(string $url): bool
    {
        $path = strtok($url, '?');
        return $path === '/' || $path === false || $path === '';
    }

    private static function install_query(int $page_id, string $town_slug, bool $is_front_page): void
    {
        global $wp_query, $wp_the_query, $post;
        $post = \get_post($page_id);
        $wp_query = new \WP_Query(['page_id' => $page_id, 'town' => $town_slug]);
        $wp_query->is_page = true;
        if ($is_front_page) {
            $wp_query->is_front_page = true;
        }
        $wp_the_query = $wp_query;
        \status_header(200);
    }

Run: vendor/bin/phpunit --filter test_route_uses_main_page_alt_term_meta_for_root_url Expected: OK (1 test, 5 assertions).

  • Step 11.3: Commit
git add tests/RouterTest.php includes/class-router.php
git commit -m "feat(wpmc S4): Router::route root URL uses main_page_alt term meta"

Task 12: Router::route — root URL fallback to index_town_alt + no-data short-circuit

Files:

  • Modify: tests/RouterTest.php

The implementation from Task 11 already handles the fallback via Helpers::index_town_alt_page_id(). This task just adds invariant tests.

  • Step 12.1: Append tests
    public function test_route_falls_back_to_index_town_alt_option(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
        $_SERVER['REQUEST_URI'] = '/tyumen/';
        \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);

        $term = new \WP_Term();
        $term->term_id = 11;
        \Brain\Monkey\Functions\when('get_term_by')->justReturn($term);

        \Brain\Monkey\Functions\when('get_field')->alias(function ($name, $where = null) {
            if ($name === 'main_town_slug') return 'spb';
            if ($name === 'page_no_rep') return false;
            if ($name === 'main_page_alt') return 0;
            if ($name === 'index_town_alt') return 777;
            return false;
        });
        \Brain\Monkey\Functions\when('get_post')->justReturn((object) ['ID' => 777]);
        \Brain\Monkey\Functions\when('status_header')->justReturn(null);

        Router::route();

        self::assertSame(777, $GLOBALS['wp_query']->constructor_args['page_id']);
    }

    public function test_route_does_not_install_query_when_neither_main_nor_fallback_set(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
        $_SERVER['REQUEST_URI'] = '/tyumen/';
        \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);

        $term = new \WP_Term();
        $term->term_id = 11;
        \Brain\Monkey\Functions\when('get_term_by')->justReturn($term);

        \Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
            if ($name === 'main_town_slug') return 'spb';
            return false;  // page_no_rep, main_page_alt, index_town_alt all false
        });
        \Brain\Monkey\Functions\expect('get_post')->never();

        Router::route();

        self::assertArrayNotHasKey('wp_query', $GLOBALS);
    }

Run: vendor/bin/phpunit tests/RouterTest.php Expected: OK (9 tests).

  • Step 12.2: Commit
git add tests/RouterTest.php
git commit -m "test(wpmc S4): Router root URL index_town_alt fallback + no-data behaviors"

Task 13: Router::route — internal page + page_unic swap

Files:

  • Modify: tests/RouterTest.php

  • Modify: includes/class-router.php

  • Step 13.1: RED — append tests

    public function test_route_resolves_internal_page_via_url_to_postid(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
        $_SERVER['REQUEST_URI'] = '/tyumen/about/';
        \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
        \Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
            if ($name === 'main_town_slug') return 'spb';
            return false;
        });
        \Brain\Monkey\Functions\expect('url_to_postid')
            ->once()
            ->with('/about/')
            ->andReturn(42);
        \Brain\Monkey\Functions\when('get_post')->justReturn((object) ['ID' => 42]);
        \Brain\Monkey\Functions\when('status_header')->justReturn(null);

        Router::route();

        self::assertSame(42, $GLOBALS['wp_query']->constructor_args['page_id']);
        self::assertSame('tyumen', $GLOBALS['wp_query']->constructor_args['town']);
        self::assertTrue($GLOBALS['wp_query']->is_page);
    }

    public function test_route_applies_page_unic_swap_when_clone_exists(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
        $_SERVER['REQUEST_URI'] = '/tyumen/about/';
        \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);

        $term = new \WP_Term();
        $term->term_id = 11;
        $term->slug = 'tyumen';
        \Brain\Monkey\Functions\when('get_term_by')->justReturn($term);

        \Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
            if ($name === 'main_town_slug') return 'spb';
            if ($name === 'page_unic') return [
                ['town' => 11, 'page_old' => 42, 'page_new' => 84],
            ];
            return false;
        });
        \Brain\Monkey\Functions\when('url_to_postid')->justReturn(42);
        \Brain\Monkey\Functions\when('get_post')->justReturn((object) ['ID' => 84]);
        \Brain\Monkey\Functions\when('status_header')->justReturn(null);

        Router::route();

        self::assertSame(84, $GLOBALS['wp_query']->constructor_args['page_id']);
    }

    public function test_route_does_not_install_query_when_url_to_postid_returns_zero(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
        $_SERVER['REQUEST_URI'] = '/tyumen/unknown/';
        \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
        \Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
            if ($name === 'main_town_slug') return 'spb';
            return false;
        });
        \Brain\Monkey\Functions\when('url_to_postid')->justReturn(0);
        \Brain\Monkey\Functions\expect('get_post')->never();

        Router::route();

        self::assertArrayNotHasKey('wp_query', $GLOBALS);
    }

Run: vendor/bin/phpunit --filter "test_route_resolves_internal_page_via_url_to_postid|test_route_applies_page_unic_swap_when_clone_exists|test_route_does_not_install_query_when_url_to_postid_returns_zero" Expected: FAIL — route() currently early-returns after root URL handling.

  • Step 13.2: GREEN — append to route() after the if (self::is_root(...)) block

Replace the comment // Internal page handling lands in Task 12. in includes/class-router.php with:

        $page_id = \url_to_postid($url_original);
        if (!$page_id) {
            return;
        }
        $swap = Helpers::page_unic_swap($page_id, $town_slug);
        if ($swap !== null) {
            $page_id = $swap;
        }
        self::install_query($page_id, $town_slug, false);

So the bottom of route() becomes:

        if (self::is_root($url_original)) {
            // ... existing root handling ...
            return;
        }

        $page_id = \url_to_postid($url_original);
        if (!$page_id) {
            return;
        }
        $swap = Helpers::page_unic_swap($page_id, $town_slug);
        if ($swap !== null) {
            $page_id = $swap;
        }
        self::install_query($page_id, $town_slug, false);
    }

Run: vendor/bin/phpunit tests/RouterTest.php Expected: OK (12 tests).

  • Step 13.3: Commit
git add tests/RouterTest.php includes/class-router.php
git commit -m "feat(wpmc S4): Router::route internal page + page_unic swap"

Task 14: Redirect — RED + GREEN

Files:

  • Create: tests/RedirectTest.php

  • Create: includes/class-redirect.php

  • Step 14.1: RED — create test file

tests/RedirectTest.php:

<?php
declare(strict_types=1);

namespace WPMultiCity\Tests;

use WPMultiCity\Redirect;

final class RedirectTest extends TestCase
{
    public function test_register_attaches_wp_action_at_priority_9998(): void
    {
        Redirect::register();

        self::assertSame(
            9998,
            has_action('wp', [Redirect::class, 'handle']),
            'Redirect::register() must hook wp action at priority 9998'
        );
    }

    public function test_handle_skips_when_current_town_equals_main(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
        $_SERVER['REQUEST_URI'] = '/spb/about/';
        \Brain\Monkey\Functions\when('get_terms')->justReturn([10 => 'spb']);
        \Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
            if ($name === 'main_town_slug') return 'spb';
            return false;
        });
        \Brain\Monkey\Functions\expect('wp_safe_redirect')->never();

        Redirect::handle();

        self::assertTrue(true);
    }

    public function test_handle_skips_when_post_not_set(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
        $_SERVER['REQUEST_URI'] = '/about/';
        \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
        \Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
            if ($name === 'main_town_slug') return 'spb';
            return false;
        });
        unset($GLOBALS['post']);
        \Brain\Monkey\Functions\expect('wp_safe_redirect')->never();

        Redirect::handle();

        self::assertTrue(true);
    }

    public function test_handle_skips_when_url_already_has_town_prefix(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
        $_SERVER['REQUEST_URI'] = '/tyumen/about/';
        \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
        \Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
            if ($name === 'main_town_slug') return 'spb';
            return false;
        });
        $GLOBALS['post'] = (object) ['ID' => 84];
        \Brain\Monkey\Functions\expect('wp_safe_redirect')->never();

        Redirect::handle();

        self::assertTrue(true);
    }

    public function test_handle_redirects_when_post_is_page_unic_clone_without_prefix(): void
    {
        \Brain\Monkey\Functions\when('is_admin')->justReturn(false);
        $_SERVER['REQUEST_URI'] = '/about/';
        \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);

        $term = new \WP_Term();
        $term->term_id = 11;
        $term->slug = 'tyumen';
        \Brain\Monkey\Functions\when('get_term_by')->justReturn($term);

        \Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
            if ($name === 'main_town_slug') return 'spb';
            if ($name === 'page_unic') return [
                ['town' => 11, 'page_old' => 42, 'page_new' => 84],
            ];
            return false;
        });
        $GLOBALS['post'] = (object) ['ID' => 84];
        \Brain\Monkey\Functions\when('get_permalink')->justReturn('https://site.com/about/');

        \Brain\Monkey\Functions\expect('wp_safe_redirect')
            ->once()
            ->with('/tyumen/about/', 301)
            ->andThrow(new \RuntimeException('redirect-called'));

        try {
            Redirect::handle();
            self::fail('Expected redirect to be issued');
        } catch (\RuntimeException $e) {
            self::assertSame('redirect-called', $e->getMessage());
        }
    }
}

Note: wp_safe_redirect followed by exit; in production would terminate the test process. The test mocks wp_safe_redirect to throw an exception, which interrupts execution before the exit and lets the test assert. The implementation MUST call wp_safe_redirect before exit (which it does).

Run: vendor/bin/phpunit tests/RedirectTest.php Expected: 5 errors — class not found.

  • Step 14.2: GREEN — create includes/class-redirect.php
<?php
declare(strict_types=1);

namespace WPMultiCity;

final class Redirect
{
    public static function register(): void
    {
        \add_action('wp', [self::class, 'handle'], 9998);
    }

    public static function handle(): void
    {
        if (\is_admin()) {
            return;
        }
        if (\defined('DOING_AJAX') && \DOING_AJAX) {
            return;
        }
        $town_slug = Helpers::current_town();
        $main = Helpers::main_town_slug();
        if ($town_slug === null || $main === null || $town_slug === $main) {
            return;
        }
        $request_uri = $_SERVER['REQUEST_URI'] ?? '/';
        if (preg_match('#^/' . preg_quote($town_slug, '#') . '(/|$)#', $request_uri)) {
            return;  // already prefixed
        }
        global $post;
        if (!isset($post->ID)) {
            return;
        }
        $current_page_id = (int) $post->ID;
        if (!\function_exists('get_field')) {
            return;
        }
        $page_unic = \get_field('page_unic', 'option');
        if (!is_array($page_unic)) {
            return;
        }
        $term = Helpers::get_term_by_slug($town_slug);
        if ($term === null) {
            return;
        }
        foreach ($page_unic as $row) {
            if (!is_array($row)) {
                continue;
            }
            if ((int) ($row['town'] ?? 0) !== $term->term_id) {
                continue;
            }
            if ((int) ($row['page_new'] ?? 0) !== $current_page_id) {
                continue;
            }
            $orig_id = (int) ($row['page_old'] ?? 0);
            if (!$orig_id) {
                return;
            }
            $orig_permalink = \get_permalink($orig_id);
            if (!is_string($orig_permalink) || $orig_permalink === '') {
                return;
            }
            $path_only = Helpers::remove_domain_link($orig_permalink);
            $redirect_to = '/' . $town_slug . $path_only;
            \wp_safe_redirect($redirect_to, 301);
            exit;
        }
    }
}

Run: vendor/bin/phpunit tests/RedirectTest.php Expected: OK (5 tests).

  • Step 14.3: Commit
git add tests/RedirectTest.php includes/class-redirect.php
git commit -m "feat(wpmc S4): Redirect class 301s on page_unic clone access"

Task 15: Plugin::boot wires OptionsPage + TermMeta + Router + Redirect

Files:

  • Create: tests/PluginBootS4Test.php

  • Modify: includes/class-plugin.php

  • Step 15.1: RED — create test file

tests/PluginBootS4Test.php:

<?php
declare(strict_types=1);

namespace WPMultiCity\Tests;

use WPMultiCity\Plugin;

final class PluginBootS4Test extends TestCase
{
    public function test_boot_wires_s4_registrations(): void
    {
        if (!class_exists('ACF', false)) {
            eval('class ACF {}');
        }

        $plugin = Plugin::instance();
        $reflection = new \ReflectionClass($plugin);
        $prop = $reflection->getProperty('booted');
        $prop->setValue($plugin, false);

        $plugin->boot();

        self::assertNotFalse(
            has_action('acf/init', ['WPMultiCity\\OptionsPage', 'do_register']),
            'boot() must register OptionsPage on acf/init'
        );
        self::assertNotFalse(
            has_action('acf/init', ['WPMultiCity\\TermMeta', 'do_register']),
            'boot() must register TermMeta on acf/init'
        );
        self::assertSame(
            9999,
            has_action('wp', ['WPMultiCity\\Router', 'route']),
            'boot() must register Router on wp@9999'
        );
        self::assertSame(
            9998,
            has_action('wp', ['WPMultiCity\\Redirect', 'handle']),
            'boot() must register Redirect on wp@9998'
        );
    }
}

Run: vendor/bin/phpunit --filter test_boot_wires_s4_registrations Expected: FAIL — none of the new classes are registered in boot().

  • Step 15.2: GREEN — modify includes/class-plugin.php

In boot(), after the existing four register calls (Taxonomy, PostType, FieldGroup, ShortcodeDmr), append:

        OptionsPage::register();
        TermMeta::register();
        Redirect::register();
        Router::register();

So the registration block becomes:

        Taxonomy::register();
        PostType::register();
        FieldGroup::register();
        ShortcodeDmr::register();
        OptionsPage::register();
        TermMeta::register();
        Redirect::register();
        Router::register();

Note: Redirect registered before Router. Both hook same wp action but at different priorities (9998 < 9999). Order of register() calls doesn't matter — WP sorts by priority.

Run: vendor/bin/phpunit --filter test_boot_wires_s4_registrations Expected: OK (1 test, 4 assertions).

  • Step 15.3: Commit
git add tests/PluginBootS4Test.php includes/class-plugin.php
git commit -m "feat(wpmc S4): Plugin::boot wires OptionsPage + TermMeta + Router + Redirect"

Task 16: Helpers::reset_cache test coverage for page_unic_cache

Files:

  • Modify: tests/HelpersTest.php

  • Step 16.1: Append test

    public function test_reset_cache_clears_page_unic_cache(): void
    {
        $term = new \WP_Term();
        $term->term_id = 11;
        \Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
        \Brain\Monkey\Functions\expect('get_field')
            ->twice()
            ->andReturn([['town' => 11, 'page_old' => 7, 'page_new' => 70]]);

        Helpers::page_unic_swap(7, 'tyumen');
        Helpers::reset_cache();
        Helpers::page_unic_swap(7, 'tyumen');

        self::assertTrue(true);
    }

Run: vendor/bin/phpunit --filter test_reset_cache_clears_page_unic_cache Expected: OK (1 test).

  • Step 16.2: Commit
git add tests/HelpersTest.php
git commit -m "test(wpmc S4): lock reset_cache invalidation for page_unic_cache"

Task 17: README roadmap status

Files:

  • Modify: README.md

  • Step 17.1: Mark S4 done

In README.md, change the S4 row from:

| S4 | URL-роутер `/{city}/{path}/` + `page_unic` + `main_page_alt` | cp-83m | open |

to:

| S4 | URL-роутер `/{city}/{path}/` + `page_unic` + `main_page_alt` | cp-83m | done |
  • Step 17.2: Commit
git add README.md
git commit -m "docs(wpmc): mark S4 done in README roadmap"

Task 18: Final verification

Files: none

  • Step 18.1: Full PHPUnit run
vendor/bin/phpunit

Expected: OK (~58 tests / ~105 assertions), exit 0, no risky/warnings.

  • Step 18.2: PHP lint every new/modified PHP file from S4
for f in includes/class-helpers.php includes/class-options-page.php includes/class-term-meta.php includes/class-router.php includes/class-redirect.php includes/class-plugin.php tests/HelpersTest.php tests/OptionsPageTest.php tests/TermMetaTest.php tests/RouterTest.php tests/RedirectTest.php tests/PluginBootS4Test.php tests/TestCase.php tests/bootstrap.php; do
  php -l "$f"
done

Expected: No syntax errors detected × 14.

  • Step 18.3: Working tree clean
git status

Expected: nothing to commit, working tree clean. If a Yandex.Disk conflict copy appears, restore from HEAD: git checkout HEAD -- <file> and delete <file> (2).php.

  • Step 18.4: Git log review
git log --oneline | head -30

Should show full S4 chain: spec → plan → WP_Query stub → main_town_slug → is_routable_path → page_unic_swap → index_town_alt_page_id → TermMeta → OptionsPage scaffold → OptionsPage register → Router scaffold → Router skip invariants → Router root + main_page_alt → Router root fallback → Router internal page + swap → Redirect → Plugin::boot S4 wire → reset_cache test → README.

  • Step 18.5: Close beads task
bd close cp-83m --reason "S4 done: legacy washanyanya-style URL router /{city}/{path}/. ACF options page (main_town_slug, index_town_alt, page_unic, page_no_rep) + term-meta main_page_alt. Router patches $wp_query on wp@9999 with page_unic clone swap. Redirect class on wp@9998 issues 301 for clone access without city prefix. ~24 new Brain Monkey tests. Plugin::boot wires 4 new registrations."

Expected: cp-83m closed; bd ready now shows cp-i17 (S5 — global filters) as next ready front under cp-cw4.


Acceptance map (spec → tasks)

Spec requirement Task(s)
WP_Query test stub + global cleanup 1
Helpers::main_town_slug() reads option 2, 8 (WP_Term branch)
Helpers::is_routable_path() with page_no_rep 3
Helpers::page_unic_swap() with cache 4, 16
Helpers::index_town_alt_page_id() 5
TermMeta registers ACF main_page_alt on taxonomy 6
OptionsPage::register hooks acf/init + main_town_slug filter 7
OptionsPage::do_register creates options page + field group with 4 fields 8
Router::register hooks wp@9999 + query_vars filter 9
Router::route skip conditions (admin, no current_town, main town, non-routable) 10
Router::route root URL with main_page_alt 11
Router::route root URL fallback to index_town_alt 12
Router::route internal page + page_unic swap 13
Redirect::handle 301 on clone access 14
Plugin::boot wires 4 new registrations 15
reset_cache covers page_unic_cache 16
README updated 17
Full green suite + lint + close beads 18