2015 lines
65 KiB
Markdown
2015 lines
65 KiB
Markdown
# 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:
|
||
|
||
```php
|
||
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
|
||
<?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:
|
||
|
||
```php
|
||
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**
|
||
|
||
```bash
|
||
vendor/bin/phpunit
|
||
```
|
||
|
||
Expected: OK (34 tests, 79 assertions).
|
||
|
||
- [ ] **Step 1.4: Commit**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```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**
|
||
|
||
```php
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```php
|
||
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**
|
||
|
||
```php
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```php
|
||
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`:
|
||
|
||
```php
|
||
private static array $page_unic_cache = [];
|
||
```
|
||
|
||
Extend `reset_cache()`:
|
||
|
||
```php
|
||
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:
|
||
|
||
```php
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```php
|
||
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**
|
||
|
||
```php
|
||
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**
|
||
|
||
```bash
|
||
vendor/bin/phpunit
|
||
```
|
||
|
||
Expected: OK (44 tests, ~89 assertions).
|
||
|
||
- [ ] **Step 5.4: Commit**
|
||
|
||
```bash
|
||
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
|
||
<?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:
|
||
|
||
```php
|
||
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
|
||
<?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**
|
||
|
||
```bash
|
||
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
|
||
<?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
|
||
<?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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```php
|
||
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`:
|
||
|
||
```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:
|
||
|
||
```php
|
||
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:
|
||
|
||
```php
|
||
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**
|
||
|
||
```bash
|
||
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
|
||
<?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
|
||
<?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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```php
|
||
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):
|
||
|
||
```php
|
||
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)**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```php
|
||
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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```php
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```php
|
||
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:
|
||
|
||
```php
|
||
$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:
|
||
|
||
```php
|
||
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**
|
||
|
||
```bash
|
||
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
|
||
<?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
|
||
<?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**
|
||
|
||
```bash
|
||
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
|
||
<?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:
|
||
|
||
```php
|
||
OptionsPage::register();
|
||
TermMeta::register();
|
||
Redirect::register();
|
||
Router::register();
|
||
```
|
||
|
||
So the registration block becomes:
|
||
|
||
```php
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```php
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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 |
|