Files
wp-multi-city/docs/superpowers/plans/2026-05-12-s2-taxonomy-cpt-acf.md
T

1096 lines
35 KiB
Markdown

# S2 — Taxonomy `town` + CPT `shortcode-town` + ACF `list_shortcode` 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:** Register WordPress taxonomy `town`, CPT `shortcode-town`, and ACF local field group `list_shortcode` inside the WP Multi City plugin, fully tested via Brain Monkey unit tests.
**Architecture:** Three independent classes (`Taxonomy`, `PostType`, `FieldGroup`) each with a static `register()` that hooks WP `init` (or `acf/init`) and a static do-method that performs the registration. `Plugin::boot()` invokes the three `register()` calls when ACF is active.
**Tech Stack:** PHP 8.0+, WordPress 6.0+, ACF Pro, Composer, PHPUnit 9.6, Brain Monkey 2.6, Mockery 1.6.
**Spec:** `docs/superpowers/specs/2026-05-12-s2-taxonomy-cpt-acf-design.md`
**Beads task:** `cp-q7g` (epic `cp-cw4`).
---
## File Structure
```
wp-multi-city/
├── wp-multi-city.php # (existing, unchanged)
├── composer.json # MODIFY — add brain/monkey, mockery
├── phpunit.xml.dist # CREATE — PHPUnit config
├── includes/
│ ├── class-plugin.php # MODIFY — boot() invokes three register()
│ ├── class-activator.php # (existing, unchanged)
│ ├── class-taxonomy.php # CREATE — WPMultiCity\Taxonomy
│ ├── class-post-type.php # CREATE — WPMultiCity\PostType
│ └── class-field-group.php # CREATE — WPMultiCity\FieldGroup
└── tests/
├── bootstrap.php # CREATE — Composer autoload
├── TestCase.php # CREATE — Brain Monkey setUp/tearDown
├── TaxonomyTest.php # CREATE
├── PostTypeTest.php # CREATE
├── FieldGroupTest.php # CREATE
└── PluginBootTest.php # CREATE
```
Boundary rules:
- One class per WP-registration concern, ≤80 lines each.
- Static methods only — no instance state. Easier to mock + assert.
- All Russian-language strings wrapped in `__('…', 'wp-multi-city')`.
---
## Working Directory
All paths in this plan are relative to:
```
/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city
```
For shell snippets, run them from that directory (the path is shell-quoted in commands).
---
## Task 1: Composer dev dependencies + PHPUnit config
**Files:**
- Modify: `composer.json`
- Create: `phpunit.xml.dist`
- Create: `tests/bootstrap.php`
- Create: `tests/TestCase.php`
- [ ] **Step 1.1: Update composer.json — add brain/monkey + mockery, autoload-dev path**
Final `composer.json`:
```json
{
"name": "bryzgalov/wp-multi-city",
"description": "Multi-city subsystem for WordPress: taxonomy town, per-city shortcodes, /{city}/{path}/ router, page clones.",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=8.0"
},
"require-dev": {
"brain/monkey": "^2.6",
"mockery/mockery": "^1.6",
"phpunit/phpunit": "^9.6"
},
"autoload-dev": {
"psr-4": {
"WPMultiCity\\Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit"
},
"config": {
"sort-packages": true
}
}
```
- [ ] **Step 1.2: Install dependencies**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
composer install
```
Expected: composer.lock created, `vendor/` populated, brain/monkey + mockery + phpunit downloaded. No errors.
- [ ] **Step 1.3: Create phpunit.xml.dist**
`phpunit.xml.dist`:
```xml
<?xml version="1.0"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true"
cacheResultFile=".phpunit.result.cache">
<testsuites>
<testsuite name="unit">
<directory>tests</directory>
<exclude>tests/bootstrap.php</exclude>
<exclude>tests/TestCase.php</exclude>
</testsuite>
</testsuites>
</phpunit>
```
- [ ] **Step 1.4: Create tests/bootstrap.php**
`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();
```
The `ABSPATH` define lets the plugin's `wp-multi-city.php` style guards pass when included from tests (we don't include the main file here — only autoload classes via the plugin's own loader, registered after include).
- [ ] **Step 1.5: Create tests/TestCase.php — base class with Brain Monkey lifecycle**
`tests/TestCase.php`:
```php
<?php
declare(strict_types=1);
namespace WPMultiCity\Tests;
use Brain\Monkey;
use Mockery;
use PHPUnit\Framework\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
}
protected function tearDown(): void
{
Monkey\tearDown();
Mockery::close();
parent::tearDown();
}
}
```
- [ ] **Step 1.6: Verify PHPUnit boots (no tests yet — runs zero)**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
vendor/bin/phpunit
```
Expected: `No tests executed!` and exit code 0 (or warning about empty suite). No fatal errors, no autoload failures.
- [ ] **Step 1.7: Commit**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
git add composer.json composer.lock phpunit.xml.dist tests/bootstrap.php tests/TestCase.php
git commit -m "test(wpmc S2): setup PHPUnit + Brain Monkey + Mockery"
```
---
## Task 2: Taxonomy `town` — RED test (init hook attached)
**Files:**
- Create: `tests/TaxonomyTest.php`
- [ ] **Step 2.1: Write failing test that Taxonomy::register() adds init action**
`tests/TaxonomyTest.php`:
```php
<?php
declare(strict_types=1);
namespace WPMultiCity\Tests;
use Brain\Monkey\Actions;
use WPMultiCity\Taxonomy;
final class TaxonomyTest extends TestCase
{
public function test_register_attaches_init_action(): void
{
Taxonomy::register();
self::assertNotFalse(
has_action('init', [Taxonomy::class, 'do_register']),
'Taxonomy::register() must attach init action with do_register handler'
);
}
}
```
- [ ] **Step 2.2: Run — expect failure (Taxonomy class does not exist)**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
vendor/bin/phpunit --filter test_register_attaches_init_action
```
Expected: FAIL — `Error: Class "WPMultiCity\Taxonomy" not found`.
---
## Task 3: Taxonomy `town` — GREEN (minimal Taxonomy::register + do_register stub)
**Files:**
- Create: `includes/class-taxonomy.php`
- [ ] **Step 3.1: Create Taxonomy class with register() that hooks init**
`includes/class-taxonomy.php`:
```php
<?php
declare(strict_types=1);
namespace WPMultiCity;
final class Taxonomy
{
public const SLUG = 'town';
public static function register(): void
{
add_action('init', [self::class, 'do_register']);
}
public static function do_register(): void
{
// Implementation filled in Task 5.
}
}
```
- [ ] **Step 3.2: Run — expect PASS**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
vendor/bin/phpunit --filter test_register_attaches_init_action
```
Expected: PASS (1 test, 1 assertion).
- [ ] **Step 3.3: Commit**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
git add tests/TaxonomyTest.php includes/class-taxonomy.php
git commit -m "feat(wpmc S2): Taxonomy::register() attaches init hook"
```
---
## Task 4: Taxonomy `town` — RED test (register_taxonomy called with correct args)
**Files:**
- Modify: `tests/TaxonomyTest.php`
- [ ] **Step 4.1: Add test that do_register() calls register_taxonomy with the right args**
Append to `tests/TaxonomyTest.php` (inside the class, after the previous method):
```php
public function test_do_register_calls_register_taxonomy_town_with_required_args(): void
{
$captured = null;
\Brain\Monkey\Functions\expect('register_taxonomy')
->once()
->with('town', ['shortcode-town'], \Mockery::on(function ($args) use (&$captured) {
$captured = $args;
return true;
}));
Taxonomy::do_register();
self::assertFalse($captured['hierarchical'], 'town must be flat');
self::assertFalse($captured['public'], 'town must not be public');
self::assertTrue($captured['show_ui'], 'town must show in admin UI');
self::assertTrue($captured['show_in_rest'], 'town must be REST-exposed');
self::assertFalse($captured['publicly_queryable']);
self::assertFalse($captured['query_var']);
self::assertFalse($captured['rewrite']);
self::assertFalse($captured['meta_box_cb']);
self::assertSame('Города', $captured['labels']['name']);
}
```
- [ ] **Step 4.2: Run — expect failure (do_register is empty, register_taxonomy never called)**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
vendor/bin/phpunit --filter test_do_register_calls_register_taxonomy_town_with_required_args
```
Expected: FAIL — Mockery exception "expected register_taxonomy to be called 1 time, called 0 times".
---
## Task 5: Taxonomy `town` — GREEN (real registration body)
**Files:**
- Modify: `includes/class-taxonomy.php`
- [ ] **Step 5.1: Replace empty do_register() with full registration**
Replace the `do_register()` body in `includes/class-taxonomy.php`:
```php
public static function do_register(): void
{
register_taxonomy(self::SLUG, ['shortcode-town'], [
'labels' => [
'name' => __('Города', 'wp-multi-city'),
'singular_name' => __('Город', 'wp-multi-city'),
'menu_name' => __('Города', 'wp-multi-city'),
'add_new_item' => __('Добавить', 'wp-multi-city'),
],
'public' => false,
'publicly_queryable' => false,
'hierarchical' => false,
'show_ui' => true,
'show_in_menu' => true,
'show_in_nav_menus' => false,
'show_admin_column' => false,
'show_in_rest' => true,
'query_var' => false,
'rewrite' => false,
'meta_box_cb' => false,
]);
}
```
- [ ] **Step 5.2: Run — expect both Taxonomy tests pass**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
vendor/bin/phpunit tests/TaxonomyTest.php
```
Expected: OK (2 tests, ≥10 assertions).
- [ ] **Step 5.3: Commit**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
git add tests/TaxonomyTest.php includes/class-taxonomy.php
git commit -m "feat(wpmc S2): register taxonomy town with required args"
```
---
## Task 6: PostType `shortcode-town` — RED test (init hook attached)
**Files:**
- Create: `tests/PostTypeTest.php`
- [ ] **Step 6.1: Write failing test**
`tests/PostTypeTest.php`:
```php
<?php
declare(strict_types=1);
namespace WPMultiCity\Tests;
use WPMultiCity\PostType;
final class PostTypeTest extends TestCase
{
public function test_register_attaches_init_action(): void
{
PostType::register();
self::assertNotFalse(
has_action('init', [PostType::class, 'do_register']),
'PostType::register() must attach init action with do_register handler'
);
}
}
```
- [ ] **Step 6.2: Run — expect failure (class does not exist)**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
vendor/bin/phpunit --filter "PostTypeTest::test_register_attaches_init_action"
```
Expected: FAIL — class WPMultiCity\PostType not found.
---
## Task 7: PostType `shortcode-town` — GREEN stub
**Files:**
- Create: `includes/class-post-type.php`
- [ ] **Step 7.1: Create PostType class with register() and empty do_register()**
`includes/class-post-type.php`:
```php
<?php
declare(strict_types=1);
namespace WPMultiCity;
final class PostType
{
public const SLUG = 'shortcode-town';
public static function register(): void
{
add_action('init', [self::class, 'do_register']);
}
public static function do_register(): void
{
// Implementation filled in Task 9.
}
}
```
- [ ] **Step 7.2: Run — expect PASS**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
vendor/bin/phpunit --filter "PostTypeTest::test_register_attaches_init_action"
```
Expected: PASS.
- [ ] **Step 7.3: Commit**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
git add tests/PostTypeTest.php includes/class-post-type.php
git commit -m "feat(wpmc S2): PostType::register() attaches init hook"
```
---
## Task 8: PostType `shortcode-town` — RED test (register_post_type called with correct args)
**Files:**
- Modify: `tests/PostTypeTest.php`
- [ ] **Step 8.1: Add second test**
Append to `tests/PostTypeTest.php` (inside the class):
```php
public function test_do_register_calls_register_post_type_shortcode_town_with_required_args(): void
{
$captured = null;
\Brain\Monkey\Functions\expect('register_post_type')
->once()
->with('shortcode-town', \Mockery::on(function ($args) use (&$captured) {
$captured = $args;
return true;
}));
PostType::do_register();
self::assertFalse($captured['public'], 'CPT must be non-public');
self::assertTrue($captured['show_ui'], 'CPT must show in admin');
self::assertSame(['title'], $captured['supports']);
self::assertTrue($captured['exclude_from_search']);
self::assertFalse($captured['publicly_queryable']);
self::assertFalse($captured['has_archive']);
self::assertFalse($captured['rewrite']);
self::assertFalse($captured['hierarchical']);
self::assertSame(8, $captured['menu_position']);
self::assertSame('Шорткоды', $captured['labels']['name']);
}
```
- [ ] **Step 8.2: Run — expect failure (register_post_type not called)**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
vendor/bin/phpunit --filter "PostTypeTest::test_do_register_calls_register_post_type_shortcode_town_with_required_args"
```
Expected: FAIL — register_post_type expected 1 time, called 0 times.
---
## Task 9: PostType `shortcode-town` — GREEN (real registration body)
**Files:**
- Modify: `includes/class-post-type.php`
- [ ] **Step 9.1: Replace empty do_register() with full registration**
Replace `do_register()` body:
```php
public static function do_register(): void
{
register_post_type(self::SLUG, [
'labels' => [
'name' => __('Шорткоды', 'wp-multi-city'),
'singular_name' => __('Шорткод', 'wp-multi-city'),
'menu_name' => __('Шорткоды', 'wp-multi-city'),
'add_new' => __('Добавить', 'wp-multi-city'),
'add_new_item' => __('Добавить шорткод', 'wp-multi-city'),
'edit_item' => __('Редактировать', 'wp-multi-city'),
'new_item' => __('Новый', 'wp-multi-city'),
'view_item' => __('Смотреть', 'wp-multi-city'),
'search_items' => __('Поиск', 'wp-multi-city'),
'not_found' => __('Не найдено', 'wp-multi-city'),
],
'public' => false,
'exclude_from_search' => true,
'publicly_queryable' => false,
'show_ui' => true,
'show_in_menu' => true,
'show_in_nav_menus' => false,
'show_in_rest' => false,
'query_var' => false,
'has_archive' => false,
'rewrite' => false,
'hierarchical' => false,
'capability_type' => 'post',
'supports' => ['title'],
'menu_position' => 8,
]);
}
```
- [ ] **Step 9.2: Run — expect all PostType tests pass**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
vendor/bin/phpunit tests/PostTypeTest.php
```
Expected: OK (2 tests, ≥11 assertions).
- [ ] **Step 9.3: Commit**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
git add tests/PostTypeTest.php includes/class-post-type.php
git commit -m "feat(wpmc S2): register CPT shortcode-town with required args"
```
---
## Task 10: FieldGroup — RED test (acf/init hook attached)
**Files:**
- Create: `tests/FieldGroupTest.php`
- [ ] **Step 10.1: Write failing test**
`tests/FieldGroupTest.php`:
```php
<?php
declare(strict_types=1);
namespace WPMultiCity\Tests;
use WPMultiCity\FieldGroup;
final class FieldGroupTest extends TestCase
{
public function test_register_attaches_acf_init_action(): void
{
FieldGroup::register();
self::assertNotFalse(
has_action('acf/init', [FieldGroup::class, 'do_register']),
'FieldGroup::register() must hook acf/init'
);
}
}
```
- [ ] **Step 10.2: Run — expect failure**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
vendor/bin/phpunit --filter "FieldGroupTest::test_register_attaches_acf_init_action"
```
Expected: FAIL — class WPMultiCity\FieldGroup not found.
---
## Task 11: FieldGroup — GREEN stub
**Files:**
- Create: `includes/class-field-group.php`
- [ ] **Step 11.1: Create FieldGroup class with register() and empty do_register()**
`includes/class-field-group.php`:
```php
<?php
declare(strict_types=1);
namespace WPMultiCity;
final class FieldGroup
{
public const GROUP_KEY = 'group_wpmc_shortcode_rows';
public const REPEATER_KEY = 'field_wpmc_list_shortcode';
public static function register(): void
{
add_action('acf/init', [self::class, 'do_register']);
}
public static function do_register(): void
{
// Implementation filled in Tasks 13 and 15.
}
}
```
- [ ] **Step 11.2: Run — expect PASS**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
vendor/bin/phpunit --filter "FieldGroupTest::test_register_attaches_acf_init_action"
```
Expected: PASS.
- [ ] **Step 11.3: Commit**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
git add tests/FieldGroupTest.php includes/class-field-group.php
git commit -m "feat(wpmc S2): FieldGroup::register() attaches acf/init hook"
```
---
## Task 12: FieldGroup — RED test (do_register is no-op when ACF missing)
**Files:**
- Modify: `tests/FieldGroupTest.php`
- [ ] **Step 12.1: Add test ensuring graceful exit when acf_add_local_field_group is undefined**
Append to `tests/FieldGroupTest.php`:
```php
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();
FieldGroup::do_register();
// No assertion needed beyond Mockery's verification of ->never().
self::assertTrue(true);
}
```
- [ ] **Step 12.2: Run — expect failure**
Currently `do_register()` is empty — `function_exists()` is mocked but never consulted. The test passes by accident. Add the active assertion that forces an early-return path:
Refine the test (replace the body added in 12.1) so that the assertion verifies the actual code path. Use this version instead:
```php
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_local_field_group')->never();
FieldGroup::do_register();
self::assertTrue(true, 'do_register must not call acf_add_local_field_group when ACF is missing');
}
```
Run:
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
vendor/bin/phpunit --filter "FieldGroupTest::test_do_register_is_noop_when_acf_missing"
```
Expected: PASS (current empty do_register satisfies the never() expectation). This test exists to guard regression once we add the real body in Task 13 — it will then fail if we forget the guard.
(Note: this is the rare TDD case where the RED step is implicit — the real RED for this contract surfaces only after Task 13 adds the registration call. Step 12 locks the invariant in.)
- [ ] **Step 12.3: Commit (test-only)**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
git add tests/FieldGroupTest.php
git commit -m "test(wpmc S2): lock ACF-missing guard for FieldGroup"
```
---
## Task 13: FieldGroup — RED test (acf_add_local_field_group called with correct structure)
**Files:**
- Modify: `tests/FieldGroupTest.php`
- [ ] **Step 13.1: Add test verifying the registered group structure**
Append to `tests/FieldGroupTest.php`:
```php
public function test_do_register_calls_acf_add_local_field_group_with_expected_structure(): 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;
}));
FieldGroup::do_register();
self::assertSame('group_wpmc_shortcode_rows', $captured['key']);
self::assertSame('WPMultiCity / Shortcode rows', $captured['title']);
self::assertCount(1, $captured['fields']);
$repeater = $captured['fields'][0];
self::assertSame('field_wpmc_list_shortcode', $repeater['key']);
self::assertSame('list_shortcode', $repeater['name']);
self::assertSame('repeater', $repeater['type']);
self::assertCount(2, $repeater['sub_fields']);
[$town, $text] = $repeater['sub_fields'];
self::assertSame('town', $town['name']);
self::assertSame('taxonomy', $town['type']);
self::assertSame('town', $town['taxonomy']);
self::assertSame('id', $town['return_format']);
self::assertSame(0, $town['allow_null']);
self::assertSame('text', $text['name']);
self::assertSame('textarea', $text['type']);
self::assertSame(
[[['param' => 'post_type', 'operator' => '==', 'value' => 'shortcode-town']]],
$captured['location']
);
}
```
- [ ] **Step 13.2: Run — expect failure (do_register is empty, acf_add_local_field_group never called)**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
vendor/bin/phpunit --filter "FieldGroupTest::test_do_register_calls_acf_add_local_field_group_with_expected_structure"
```
Expected: FAIL — acf_add_local_field_group expected 1 time, called 0 times.
---
## Task 14: FieldGroup — GREEN (real registration body)
**Files:**
- Modify: `includes/class-field-group.php`
- [ ] **Step 14.1: Replace empty do_register() with full ACF group registration**
Replace `do_register()` body in `includes/class-field-group.php`:
```php
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 / Shortcode rows',
'fields' => [
[
'key' => self::REPEATER_KEY,
'label' => __('Подстановки по городам', 'wp-multi-city'),
'name' => 'list_shortcode',
'type' => 'repeater',
'layout' => 'table',
'button_label' => __('Добавить город', 'wp-multi-city'),
'sub_fields' => [
[
'key' => 'field_wpmc_list_shortcode_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_list_shortcode_text',
'label' => __('Значение', 'wp-multi-city'),
'name' => 'text',
'type' => 'textarea',
'rows' => 4,
],
],
],
],
'location' => [
[
['param' => 'post_type', 'operator' => '==', 'value' => 'shortcode-town'],
],
],
'menu_order' => 0,
'position' => 'normal',
'style' => 'default',
'active' => true,
]);
}
```
- [ ] **Step 14.2: Run all FieldGroup tests — expect all pass**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
vendor/bin/phpunit tests/FieldGroupTest.php
```
Expected: OK (3 tests, ≥14 assertions).
- [ ] **Step 14.3: Commit**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
git add tests/FieldGroupTest.php includes/class-field-group.php
git commit -m "feat(wpmc S2): register ACF group list_shortcode for shortcode-town"
```
---
## Task 15: Plugin::boot() — RED test (boot wires up the three registrations)
**Files:**
- Create: `tests/PluginBootTest.php`
- [ ] **Step 15.1: Write failing test**
`tests/PluginBootTest.php`:
```php
<?php
declare(strict_types=1);
namespace WPMultiCity\Tests;
use WPMultiCity\Plugin;
final class PluginBootTest extends TestCase
{
public function test_boot_attaches_init_and_acf_init_hooks_when_acf_present(): void
{
// Simulate ACF being active so boot() proceeds past the dependency guard.
if (!class_exists('ACF', false)) {
eval('class ACF {}');
}
Plugin::instance()->boot();
self::assertNotFalse(
has_action('init', ['WPMultiCity\\Taxonomy', 'do_register']),
'boot() must register Taxonomy on init'
);
self::assertNotFalse(
has_action('init', ['WPMultiCity\\PostType', 'do_register']),
'boot() must register PostType on init'
);
self::assertNotFalse(
has_action('acf/init', ['WPMultiCity\\FieldGroup', 'do_register']),
'boot() must register FieldGroup on acf/init'
);
}
}
```
Note: `Plugin::instance()` is a singleton — tests share it. The Brain Monkey `Actions` registry is reset between tests via `Monkey\tearDown()`, so duplicate `add_action` calls across tests are harmless within a single test run.
- [ ] **Step 15.2: Run — expect failure**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
vendor/bin/phpunit --filter "PluginBootTest::test_boot_attaches_init_and_acf_init_hooks_when_acf_present"
```
Expected: FAIL — `has_action` returns false because `Plugin::boot()` currently has empty body (only the dependency check + comment).
---
## Task 16: Plugin::boot() — GREEN (call the three register() methods)
**Files:**
- Modify: `includes/class-plugin.php:37-45` (boot method body)
- [ ] **Step 16.1: Update boot() to invoke the three register() methods**
Replace the `boot()` method body in `includes/class-plugin.php`:
```php
public function boot(): void
{
if (!$this->dependencies_satisfied()) {
add_action('admin_notices', [$this, 'render_dependency_notice']);
return;
}
Taxonomy::register();
PostType::register();
FieldGroup::register();
}
```
- [ ] **Step 16.2: Run — expect all tests pass**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
vendor/bin/phpunit
```
Expected: OK (8 tests, ≥35 assertions). All four test files green.
- [ ] **Step 16.3: Commit**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
git add tests/PluginBootTest.php includes/class-plugin.php
git commit -m "feat(wpmc S2): Plugin::boot() wires Taxonomy + PostType + FieldGroup"
```
---
## Task 17: Update README roadmap status
**Files:**
- Modify: `README.md` lines 22-29 (roadmap table)
- [ ] **Step 17.1: Mark S2 as done in the roadmap**
In `README.md`, change the S2 row:
```
| S2 | Taxonomy `town` + CPT `shortcode-town` + ACF `list_shortcode` | cp-q7g | open |
```
to:
```
| S2 | Taxonomy `town` + CPT `shortcode-town` + ACF `list_shortcode` | cp-q7g | done |
```
And update the S1 status row from `in progress` to `done`:
```
| S1 | Скелет (header, autoload, bootstrap) | cp-8ci | in progress |
```
to:
```
| S1 | Скелет (header, autoload, bootstrap) | cp-8ci | done |
```
- [ ] **Step 17.2: Commit**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
git add README.md
git commit -m "docs(wpmc): mark S1+S2 done in README roadmap"
```
---
## Task 18: Final verification
**Files:** none
- [ ] **Step 18.1: Full PHPUnit run**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
vendor/bin/phpunit
```
Expected: OK (8 tests, ≥35 assertions), exit code 0, no risky / no warnings.
- [ ] **Step 18.2: PHP syntax lint on every new file**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
for f in includes/class-taxonomy.php includes/class-post-type.php includes/class-field-group.php includes/class-plugin.php tests/TestCase.php tests/TaxonomyTest.php tests/PostTypeTest.php tests/FieldGroupTest.php tests/PluginBootTest.php tests/bootstrap.php; do
php -l "$f"
done
```
Expected: `No syntax errors detected` for every file.
- [ ] **Step 18.3: Git log review**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city"
git log --oneline
```
Expected: chain of commits from `chore: init wp-multi-city …` through `docs(wpmc): mark S1+S2 done …`, each commit doing one logical thing.
- [ ] **Step 18.4: Close beads task**
```bash
bd close cp-q7g --reason "S2 done: taxonomy town + CPT shortcode-town + ACF list_shortcode registered with unit tests (Brain Monkey, 8 tests green)"
```
Expected: cp-q7g closed; `bd ready` now lists cp-arq (S3 shortcode_dmr) as the next ready front under epic cp-cw4.
---
## Acceptance map (spec → tasks)
| Spec requirement | Task |
|---|---|
| Brain Monkey + Mockery dev deps | 1 |
| PHPUnit config + bootstrap + TestCase | 1 |
| Taxonomy `town` with `hierarchical=false`, `show_in_rest=true`, `public=false`, `meta_box_cb=false` | 2-5 |
| CPT `shortcode-town` with `public=false`, `show_ui=true`, `supports=['title']`, `menu_position=8` | 6-9 |
| ACF group `group_wpmc_shortcode_rows` titled `WPMultiCity / Shortcode rows` | 13-14 |
| Repeater `list_shortcode` with sub-fields `town` (taxonomy, return_format=id) and `text` (textarea) | 13-14 |
| Group location = post_type==shortcode-town | 13-14 |
| Graceful no-op when ACF function missing | 12, 14 |
| `Plugin::boot()` wires up all three registrations | 15-16 |
| README roadmap status updated | 17 |
| Full green test run | 18 |
| Close cp-q7g | 18 |