# 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 tests tests/bootstrap.php tests/TestCase.php ``` - [ ] **Step 1.4: Create tests/bootstrap.php** `tests/bootstrap.php`: ```php 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 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 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 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 |