# S2 — Taxonomy `town` + CPT `shortcode-town` + ACF `list_shortcode` - Beads: `cp-q7g` (parent epic `cp-cw4`) - Depends on: `cp-8ci` (S1 — скелет плагина, закрыт) - Blocks: `cp-arq` (S3 — `[shortcode_dmr]` handler) - Дата: 2026-05-12 ## Цель Подсистема хранения для мультигородского контента: справочник городов (taxonomy), внутренний CPT-контейнер «шорткодов», и ACF-репитер строк подстановок «город → значение». Воспроизводит структуру темы washanyanya, но регистрируется только в коде (не через ACF UI) — чтобы плагин активировался на новых сайтах без миграции БД. ## Архитектура Три независимых класса в `includes/`, каждый со статическим `register()`: ``` includes/ ├── class-plugin.php ← Plugin::boot() вызывает три register() ├── class-activator.php ← без изменений ├── class-taxonomy.php ← WPMultiCity\Taxonomy ├── class-post-type.php ← WPMultiCity\PostType └── class-field-group.php ← WPMultiCity\FieldGroup ``` Имена классов совместимы с S1 autoloader (`Plugin::register_autoloader`): `WPMultiCity\PostType` → `includes/class-post-type.php`, etc. ### Plugin::boot() После проверки `dependencies_satisfied()`: ```php Taxonomy::register(); // add_action('init', [self::class, 'do_register']) PostType::register(); // add_action('init', [self::class, 'do_register']) FieldGroup::register(); // add_action('acf/init', [self::class, 'do_register']) ``` Каждый `register()` идемпотентен (вешает свой add_action один раз) и сам по себе ничего не регистрирует — регистрация происходит в hook-callback. Это даёт чистое разделение «когда вешаем хук» (boot) и «что делаем в хуке» (do_register) для тестируемости. ## Регистрационные параметры ### Taxonomy `town` ```php register_taxonomy('town', ['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, ]); ``` Решения: - `hierarchical=false` — города плоские (acceptance cp-q7g). - `public=false` + `publicly_queryable=false` — нет publik /town// архива; роутер /{city}/{path}/ строится отдельно в S4. - `show_in_rest=true` — на случай Gutenberg-блоков и REST API клиентов. - `meta_box_cb=false` — на CPT edit-screen мета-бокс таксы не нужен (term выбирается в ACF repeater подстановок). - `rewrite=false` — никаких URL-конфликтов с роутером S4. ### CPT `shortcode-town` ```php register_post_type('shortcode-town', [ '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, ]); ``` CPT — служебный контейнер. `show_in_rest=false` (acceptance молчит, держим minimal scope). ### ACF group `WPMultiCity / Shortcode rows` ```php acf_add_local_field_group([ 'key' => 'group_wpmc_shortcode_rows', 'title' => 'WPMultiCity / Shortcode rows', 'fields' => [ [ 'key' => 'field_wpmc_list_shortcode', '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, ]); ``` Под-поле `town`: - `return_format=id` — `get_field('list_shortcode', $id)` отдаёт массив со строками вида `['town' => 42, 'text' => '...']`. Это то, что ждёт S3 (`add_shortcode_dmr` на washanyanya читает `$item['town']` как ID и затем `get_term($item['town'], 'town')`). - `allow_null=0` — пустой город в строке репитера бессмыслен. ## Тестирование (Brain Monkey + Mockery) Composer dev-deps: - `brain/monkey: ^2.6` - `mockery/mockery: ^1.6` - `phpunit/phpunit: ^9.6` (уже есть) Структура: ``` tests/ ├── bootstrap.php ← require vendor/autoload.php ├── TestCase.php ← Brain\Monkey\setUp/tearDown + Mockery\close() ├── TaxonomyTest.php ├── PostTypeTest.php ├── FieldGroupTest.php └── PluginBootTest.php phpunit.xml.dist ``` Тестовые сценарии (RED → GREEN порядок): 1. **TaxonomyTest::test_register_adds_init_action** — после `Taxonomy::register()` Brain Monkey подтверждает что `add_action('init', ...)` был вызван. 2. **TaxonomyTest::test_init_callback_registers_taxonomy_town** — выполнение callback вызывает `register_taxonomy('town', ['shortcode-town'], $args)` с обязательными ключами (`hierarchical=false`, `show_in_rest=true`, `public=false`). 3. **PostTypeTest::test_register_adds_init_action** — аналогично. 4. **PostTypeTest::test_init_callback_registers_post_type_shortcode_town** — `register_post_type('shortcode-town', $args)` с `public=false`, `show_ui=true`, `supports=['title']`. 5. **FieldGroupTest::test_register_adds_acf_init_action** — после `FieldGroup::register()` Brain Monkey подтверждает `add_action('acf/init', ...)`. 6. **FieldGroupTest::test_acf_init_callback_registers_local_field_group** — `acf_add_local_field_group()` вызывается с key=`group_wpmc_shortcode_rows`, наличием repeater `list_shortcode` и двух sub-fields с правильными `name`/`type`/`taxonomy`/`return_format`. 7. **FieldGroupTest::test_acf_init_callback_noop_when_acf_missing** — если `function_exists('acf_add_local_field_group')` возвращает false, callback не падает. 8. **PluginBootTest::test_boot_invokes_three_registrations** — `Plugin::instance()->boot()` (когда зависимости удовлетворены) вызывает `Taxonomy::register`, `PostType::register`, `FieldGroup::register`. Реализуется через Brain Monkey `Actions\expectAdded`. Каждый тест — отдельный RED → GREEN коммит. ## Acceptance verification (после green-тестов) 1. `composer install && vendor/bin/phpunit` — все тесты зелёные. 2. Активировать плагин на чистом WP с ACF Pro (можно washanyanya local-копии не нужен — wp-cli + новый WP): - admin menu показывает «Шорткоды» (CPT) и «Города» (taxonomy в его подменю); - открыть «Шорткоды → Добавить» — виден repeater «Подстановки по городам» с двумя sub-fields. 3. Деактивация плагина → пункты меню исчезают. ## Что НЕ входит в S2 - Term-meta `main_page_alt` на term'е town — S4 (cp-83m). - Shortcode `[shortcode_dmr]` handler + хелперы `current_town`, `_alt_get_field`, `remove_domain_link` — S3 (cp-arq). - Term ACF `procent` (цены) — out of MVP. - Полный WP integration test suite — S6 (cp-la7). ## Open questions Нет. Все решения зафиксированы выше.