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

35 KiB

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:

{
  "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
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 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
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
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)
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
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
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)
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
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
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
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):

    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)
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:

    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
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
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
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)
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
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
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
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):

    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)
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:

    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
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
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
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
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
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
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
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:

    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:

    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:

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)
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:

    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)
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:

    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
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
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
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
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:

    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
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
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
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
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
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
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
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