# S3 — `[shortcode_dmr]` + helpers 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 multi-city content substitution core: shortcode `[shortcode_dmr id=N]` plus public helpers `wpmc_current_town`, `wpmc_get_term_by_slug`, `wpmc_alt_get_field`, `wpmc_remove_domain_link`. **Architecture:** Static `WPMultiCity\Helpers` class for implementation. Static `WPMultiCity\ShortcodeDmr` class registers and renders the shortcode. `includes/wpmc-functions.php` defines thin procedural `wpmc_*` wrappers, loaded unconditionally from the main plugin file. `Plugin::boot()` adds `ShortcodeDmr::register()` alongside the existing three S2 registrations. **Tech Stack:** PHP 8.0+, WordPress 6.0+, ACF Pro, PHPUnit 9.6, Brain Monkey 2.7, Mockery 1.6, Patchwork (for `function_exists` shimming). **Spec:** `docs/superpowers/specs/2026-05-12-s3-shortcode-dmr-helpers-design.md` **Beads task:** `cp-arq` (epic `cp-cw4`). --- ## Working Directory All paths relative to: ``` /Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/Плагины Wordpress/wp-multi-city ``` Run shell commands from that directory (quote it for shells that can't handle the Cyrillic/spaces). --- ## File Structure ``` wp-multi-city/ ├── wp-multi-city.php # MODIFY — require wpmc-functions.php ├── includes/ │ ├── class-plugin.php # MODIFY — boot() also calls ShortcodeDmr::register() │ ├── class-helpers.php # CREATE — WPMultiCity\Helpers static utility │ ├── class-shortcode-dmr.php # CREATE — WPMultiCity\ShortcodeDmr │ └── wpmc-functions.php # CREATE — procedural wpmc_* wrappers └── tests/ ├── TestCase.php # MODIFY — reset caches in setUp ├── HelpersTest.php # CREATE — all helper tests ├── ShortcodeDmrTest.php # CREATE — shortcode handler tests └── PluginBootShortcodeDmrTest.php # CREATE — boot wires ShortcodeDmr ``` Boundaries: - `Helpers` — pure utility (no hooks of its own). - `ShortcodeDmr` — only the shortcode lifecycle (`register`, `do_register`, `render`). - `wpmc-functions.php` — only public procedural shims; never grows past one-line wrappers. --- ## Task 1: Helpers scaffold + per-request cache plumbing **Files:** - Create: `includes/class-helpers.php` - Modify: `tests/TestCase.php` This task only creates the empty class with `reset_cache()` and wires it into TestCase setUp so subsequent tests start clean. No public methods yet — they get added with their own RED→GREEN cycles in Tasks 2-7. - [ ] **Step 1.1: Create skeleton class** `includes/class-helpers.php`: ```php term_id = 42; $term->slug = 'tyumen'; $term->taxonomy = 'town'; \Brain\Monkey\Functions\expect('get_term_by') ->once() ->with('slug', 'tyumen', 'town') ->andReturn($term); $result = Helpers::get_term_by_slug('tyumen'); self::assertInstanceOf(\WP_Term::class, $result); self::assertSame(42, $result->term_id); self::assertSame('tyumen', $result->slug); } } ``` - [ ] **Step 2.2: Run — expect failure (method does not exist)** ```bash vendor/bin/phpunit --filter test_get_term_by_slug_returns_wp_term_when_found ``` Expected: Error — `Call to undefined method WPMultiCity\Helpers::get_term_by_slug`. --- ## Task 3: `Helpers::get_term_by_slug` — GREEN + cache test **Files:** - Modify: `includes/class-helpers.php` - Modify: `tests/HelpersTest.php` - [ ] **Step 3.1: Add method to Helpers** Append to `includes/class-helpers.php` inside the class (before the closing brace): ```php public static function get_term_by_slug(string $slug, string $taxonomy = 'town'): ?\WP_Term { $key = $taxonomy . '|' . $slug; if (array_key_exists($key, self::$term_cache)) { return self::$term_cache[$key]; } $term = \get_term_by('slug', $slug, $taxonomy); return self::$term_cache[$key] = ($term instanceof \WP_Term) ? $term : null; } ``` - [ ] **Step 3.2: Run — expect PASS** ```bash vendor/bin/phpunit --filter test_get_term_by_slug_returns_wp_term_when_found ``` Expected: OK (1 test, 3 assertions). - [ ] **Step 3.3: Add cache-hit + not-found tests** Append to the `HelpersTest` class body: ```php public function test_get_term_by_slug_returns_null_when_not_found(): void { \Brain\Monkey\Functions\expect('get_term_by') ->once() ->with('slug', 'nonexistent', 'town') ->andReturn(false); self::assertNull(Helpers::get_term_by_slug('nonexistent')); } public function test_get_term_by_slug_caches_results_within_request(): void { \Brain\Monkey\Functions\expect('get_term_by') ->once() ->with('slug', 'tyumen', 'town') ->andReturn(false); Helpers::get_term_by_slug('tyumen'); Helpers::get_term_by_slug('tyumen'); Helpers::get_term_by_slug('tyumen'); // Mockery ->once() at teardown verifies the cache stopped repeated calls. self::assertTrue(true); } ``` - [ ] **Step 3.4: Run — both new tests must pass** ```bash vendor/bin/phpunit tests/HelpersTest.php ``` Expected: OK (3 tests, ≥4 assertions). - [ ] **Step 3.5: Commit** ```bash git add includes/class-helpers.php tests/HelpersTest.php git commit -m "feat(wpmc S3): Helpers::get_term_by_slug with per-request cache" ``` --- ## Task 4: `Helpers::current_town` — RED test (URI matches existing term) **Files:** - Modify: `tests/HelpersTest.php` - [ ] **Step 4.1: Add failing test** Append to `HelpersTest`: ```php public function test_current_town_returns_slug_when_uri_matches_existing_town(): void { $_SERVER['REQUEST_URI'] = '/tyumen/some-service/'; \Brain\Monkey\Functions\expect('get_terms') ->once() ->with('town', \Mockery::on(function ($args) { return ($args['hide_empty'] ?? null) === false && ($args['fields'] ?? null) === 'id=>slug'; })) ->andReturn([10 => 'spb', 11 => 'tyumen']); self::assertSame('tyumen', Helpers::current_town()); } ``` - [ ] **Step 4.2: Run — expect FAIL (method does not exist)** ```bash vendor/bin/phpunit --filter test_current_town_returns_slug_when_uri_matches_existing_town ``` Expected: Error — `Call to undefined method WPMultiCity\Helpers::current_town`. --- ## Task 5: `Helpers::current_town` — GREEN minimal (URI match path only) **Files:** - Modify: `includes/class-helpers.php` - [ ] **Step 5.1: Add method that handles URI-match path only** Append to Helpers class: ```php public static function current_town(): ?string { if (self::$current_town_resolved) { return self::$current_town_memo; } $uri = $_SERVER['REQUEST_URI'] ?? '/'; $first_segment = self::first_path_segment($uri); $resolved = null; if ($first_segment !== null) { $terms = \get_terms('town', [ 'hide_empty' => false, 'fields' => 'id=>slug', ]); if (is_array($terms) && in_array($first_segment, $terms, true)) { $resolved = $first_segment; } } if ($resolved === null) { $resolved = \apply_filters('wpmc_main_town_slug', null); if (!is_string($resolved) || $resolved === '') { $resolved = null; } } $resolved = \apply_filters('wpmc_current_town_slug', $resolved); if (!is_string($resolved) || $resolved === '') { $resolved = null; } self::$current_town_memo = $resolved; self::$current_town_resolved = true; return $resolved; } private static function first_path_segment(string $uri): ?string { $path = strtok($uri, '?'); if ($path === false) { return null; } $parts = array_values(array_filter(explode('/', $path), static fn(string $p) => $p !== '')); return $parts[0] ?? null; } ``` - [ ] **Step 5.2: Run — expect PASS** ```bash vendor/bin/phpunit --filter test_current_town_returns_slug_when_uri_matches_existing_town ``` Expected: OK (1 test, 1 assertion). - [ ] **Step 5.3: Commit** ```bash git add includes/class-helpers.php tests/HelpersTest.php git commit -m "feat(wpmc S3): Helpers::current_town URI-match path" ``` --- ## Task 6: `Helpers::current_town` — add fallback + filter tests **Files:** - Modify: `tests/HelpersTest.php` - [ ] **Step 6.1: Add 5 more tests (no-match→null, main_town_slug fallback, URI wins over fallback, current_town_slug override, memoization)** Append to HelpersTest class: ```php public function test_current_town_returns_null_when_uri_does_not_match_and_no_filter(): void { $_SERVER['REQUEST_URI'] = '/some-random-page/'; \Brain\Monkey\Functions\expect('get_terms') ->andReturn([10 => 'spb', 11 => 'tyumen']); self::assertNull(Helpers::current_town()); } public function test_current_town_uses_main_town_slug_filter_when_uri_has_no_match(): void { $_SERVER['REQUEST_URI'] = '/'; \Brain\Monkey\Functions\expect('get_terms') ->andReturn([10 => 'spb', 11 => 'tyumen']); \Brain\Monkey\Filters\expectApplied('wpmc_main_town_slug') ->once() ->with(null) ->andReturn('spb'); self::assertSame('spb', Helpers::current_town()); } public function test_current_town_uri_match_wins_over_main_town_filter(): void { $_SERVER['REQUEST_URI'] = '/tyumen/'; \Brain\Monkey\Functions\expect('get_terms') ->andReturn([10 => 'spb', 11 => 'tyumen']); // main_town filter should not be consulted when URI matches. \Brain\Monkey\Filters\expectApplied('wpmc_main_town_slug')->never(); self::assertSame('tyumen', Helpers::current_town()); } public function test_current_town_slug_filter_overrides_final_value(): void { $_SERVER['REQUEST_URI'] = '/'; \Brain\Monkey\Functions\expect('get_terms') ->andReturn([]); \Brain\Monkey\Filters\expectApplied('wpmc_current_town_slug') ->once() ->andReturn('override-slug'); self::assertSame('override-slug', Helpers::current_town()); } public function test_current_town_memoizes_within_request(): void { $_SERVER['REQUEST_URI'] = '/tyumen/'; \Brain\Monkey\Functions\expect('get_terms') ->once() ->andReturn([10 => 'spb', 11 => 'tyumen']); Helpers::current_town(); Helpers::current_town(); Helpers::current_town(); self::assertTrue(true); } ``` - [ ] **Step 6.2: Run — expect all 5 new tests pass** ```bash vendor/bin/phpunit tests/HelpersTest.php ``` Expected: OK (8 tests, ≥9 assertions). Failure in any of these 5 = bug in Task 5 implementation. - [ ] **Step 6.3: Commit** ```bash git add tests/HelpersTest.php git commit -m "test(wpmc S3): current_town fallback + filter + memoization coverage" ``` --- ## Task 7: `Helpers::alt_get_field` — RED test (string value goes through do_shortcode) **Files:** - Modify: `tests/HelpersTest.php` - [ ] **Step 7.1: Add failing test** Append to HelpersTest: ```php public function test_alt_get_field_applies_do_shortcode_to_string_values(): void { \Brain\Monkey\Functions\when('function_exists')->alias(function ($name) { return $name === 'get_field'; }); \Brain\Monkey\Functions\expect('get_field') ->once() ->with('headline', 5, true) ->andReturn('Hello [bar]'); \Brain\Monkey\Functions\expect('do_shortcode') ->once() ->with('Hello [bar]') ->andReturn('Hello World'); self::assertSame('Hello World', Helpers::alt_get_field('headline', 5)); } ``` - [ ] **Step 7.2: Run — expect FAIL** ```bash vendor/bin/phpunit --filter test_alt_get_field_applies_do_shortcode_to_string_values ``` Expected: Error — undefined method. --- ## Task 8: `Helpers::alt_get_field` — GREEN **Files:** - Modify: `includes/class-helpers.php` - [ ] **Step 8.1: Add method** Append to Helpers class: ```php public static function alt_get_field(string $selector, $post_id = false, bool $format_value = true) { $post_id = \apply_filters('wpmc_alt_get_field_post_id', $post_id, $selector); if (!\function_exists('get_field')) { return null; } $value = \get_field($selector, $post_id, $format_value); return is_string($value) ? \do_shortcode($value) : $value; } ``` - [ ] **Step 8.2: Run — expect PASS** ```bash vendor/bin/phpunit --filter test_alt_get_field_applies_do_shortcode_to_string_values ``` Expected: OK (1 test, 1 assertion). - [ ] **Step 8.3: Add 3 more tests (non-string, filter, ACF missing)** Append: ```php public function test_alt_get_field_returns_arrays_as_is(): void { \Brain\Monkey\Functions\when('function_exists')->alias(fn($n) => $n === 'get_field'); \Brain\Monkey\Functions\expect('get_field') ->once() ->andReturn(['a', 'b']); \Brain\Monkey\Functions\expect('do_shortcode')->never(); self::assertSame(['a', 'b'], Helpers::alt_get_field('list')); } public function test_alt_get_field_post_id_filter_overrides_caller_id(): void { \Brain\Monkey\Functions\when('function_exists')->alias(fn($n) => $n === 'get_field'); \Brain\Monkey\Filters\expectApplied('wpmc_alt_get_field_post_id') ->once() ->with(5, 'title') ->andReturn(99); \Brain\Monkey\Functions\expect('get_field') ->once() ->with('title', 99, true) ->andReturn(null); Helpers::alt_get_field('title', 5); self::assertTrue(true); } public function test_alt_get_field_returns_null_when_acf_get_field_missing(): void { \Brain\Monkey\Functions\when('function_exists')->justReturn(false); \Brain\Monkey\Functions\expect('get_field')->never(); self::assertNull(Helpers::alt_get_field('anything')); } ``` - [ ] **Step 8.4: Run — expect all 4 alt_get_field tests pass** ```bash vendor/bin/phpunit --filter alt_get_field ``` Expected: OK (4 tests, ≥4 assertions). - [ ] **Step 8.5: Commit** ```bash git add includes/class-helpers.php tests/HelpersTest.php git commit -m "feat(wpmc S3): Helpers::alt_get_field with do_shortcode + filter" ``` --- ## Task 9: `Helpers::remove_domain_link` — full TDD cycle (5 cases at once) **Files:** - Modify: `tests/HelpersTest.php` - Modify: `includes/class-helpers.php` This helper is small and the cases are independent — TDD as one RED→GREEN pair with five assertions, then commit. - [ ] **Step 9.1: Add failing test (all 5 cases)** Append to HelpersTest: ```php public function test_remove_domain_link_handles_various_url_shapes(): void { self::assertSame('/foo/bar/', Helpers::remove_domain_link('https://site.com/foo/bar/')); self::assertSame('/foo?q=1#a', Helpers::remove_domain_link('http://site.com/foo?q=1#a')); self::assertSame('/foo/', Helpers::remove_domain_link('/foo/')); self::assertSame('mailto:x@y.com', Helpers::remove_domain_link('mailto:x@y.com')); self::assertSame('not a url', Helpers::remove_domain_link('not a url')); } ``` - [ ] **Step 9.2: Run — expect FAIL (method undefined)** ```bash vendor/bin/phpunit --filter test_remove_domain_link_handles_various_url_shapes ``` Expected: Error — `Call to undefined method`. - [ ] **Step 9.3: Implement method** Append to Helpers: ```php public static function remove_domain_link(string $url): string { $parsed = parse_url($url); if ($parsed === false || empty($parsed['scheme'])) { return $url; } $scheme = strtolower((string) $parsed['scheme']); if ($scheme !== 'http' && $scheme !== 'https') { return $url; } $path = $parsed['path'] ?? ''; $query = isset($parsed['query']) ? '?' . $parsed['query'] : ''; $fragment = isset($parsed['fragment']) ? '#' . $parsed['fragment'] : ''; return $path . $query . $fragment; } ``` - [ ] **Step 9.4: Run — expect PASS** ```bash vendor/bin/phpunit --filter test_remove_domain_link_handles_various_url_shapes ``` Expected: OK (1 test, 5 assertions). - [ ] **Step 9.5: Commit** ```bash git add includes/class-helpers.php tests/HelpersTest.php git commit -m "feat(wpmc S3): Helpers::remove_domain_link via parse_url" ``` --- ## Task 10: `ShortcodeDmr` scaffold + register hook test (TDD pair) **Files:** - Create: `tests/ShortcodeDmrTest.php` - Create: `includes/class-shortcode-dmr.php` - [ ] **Step 10.1: RED — write failing test** `tests/ShortcodeDmrTest.php`: ```php once() ->with('shortcode_dmr', [ShortcodeDmr::class, 'render']); ShortcodeDmr::do_register(); self::assertTrue(true); } ``` - [ ] **Step 11.2: Run — expect PASS (do_register already calls add_shortcode from Task 10's stub)** ```bash vendor/bin/phpunit --filter test_do_register_registers_shortcode ``` Expected: OK (1 test, 1 assertion). The stub from Task 10 already implements the correct behavior; this test is the regression lock. - [ ] **Step 11.3: Commit (test-only)** ```bash git add tests/ShortcodeDmrTest.php git commit -m "test(wpmc S3): lock add_shortcode call in ShortcodeDmr::do_register" ``` --- ## Task 12: `render` — RED test (matching row returns text) **Files:** - Modify: `tests/ShortcodeDmrTest.php` - [ ] **Step 12.1: Add failing test** Append: ```php public function test_render_returns_text_when_row_matches_current_town(): void { $_SERVER['REQUEST_URI'] = '/tyumen/'; \Brain\Monkey\Functions\when('get_terms')->justReturn([10 => 'spb', 11 => 'tyumen']); $term = new \WP_Term(); $term->term_id = 11; $term->slug = 'tyumen'; \Brain\Monkey\Functions\when('get_term_by')->alias(function ($field, $slug, $tax) use ($term) { return ($field === 'slug' && $slug === 'tyumen' && $tax === 'town') ? $term : false; }); \Brain\Monkey\Functions\expect('get_field') ->once() ->with('list_shortcode', 42) ->andReturn([ ['town' => 10, 'text' => 'СПб-текст'], ['town' => 11, 'text' => 'Тюмень-текст'], ]); $result = ShortcodeDmr::render(['id' => 42]); self::assertSame('Тюмень-текст', $result); } ``` - [ ] **Step 12.2: Run — expect FAIL (render still returns '')** ```bash vendor/bin/phpunit --filter test_render_returns_text_when_row_matches_current_town ``` Expected: FAIL — `Failed asserting that '' matches expected 'Тюмень-текст'`. --- ## Task 13: `render` — GREEN minimal implementation **Files:** - Modify: `includes/class-shortcode-dmr.php` - [ ] **Step 13.1: Replace render method body** Replace the `render()` method in `includes/class-shortcode-dmr.php` with: ```php public static function render($atts = [], $content = null): string { $atts = \shortcode_atts(['id' => 0], (array) $atts); $id = (int) $atts['id']; if ($id <= 0) { return ''; } $town_slug = Helpers::current_town(); if ($town_slug === null) { return ''; } $cache_key = $id . '|' . $town_slug; if (array_key_exists($cache_key, self::$render_cache)) { return self::$render_cache[$cache_key]; } $term = Helpers::get_term_by_slug($town_slug); if ($term === null) { return self::$render_cache[$cache_key] = ''; } $rows = \get_field('list_shortcode', $id); if (!is_array($rows) || $rows === []) { return self::$render_cache[$cache_key] = ''; } foreach ($rows as $row) { if (!is_array($row) || !isset($row['town'])) { continue; } if ((int) $row['town'] === (int) $term->term_id) { $text = isset($row['text']) ? (string) $row['text'] : ''; return self::$render_cache[$cache_key] = $text; } } return self::$render_cache[$cache_key] = ''; } ``` - [ ] **Step 13.2: Run — expect PASS** ```bash vendor/bin/phpunit --filter test_render_returns_text_when_row_matches_current_town ``` Expected: OK (1 test, 1 assertion). - [ ] **Step 13.3: Commit** ```bash git add tests/ShortcodeDmrTest.php includes/class-shortcode-dmr.php git commit -m "feat(wpmc S3): ShortcodeDmr::render resolves text by current town" ``` --- ## Task 14: `render` — edge cases (id<=0, current_town null, term null, empty rows, no match) **Files:** - Modify: `tests/ShortcodeDmrTest.php` - [ ] **Step 14.1: Add 5 edge-case tests** Append to ShortcodeDmrTest: ```php public function test_render_returns_empty_when_id_is_zero(): void { \Brain\Monkey\Functions\expect('get_field')->never(); self::assertSame('', ShortcodeDmr::render(['id' => 0])); } public function test_render_returns_empty_when_current_town_is_null(): void { $_SERVER['REQUEST_URI'] = '/no-matching-segment/'; \Brain\Monkey\Functions\when('get_terms')->justReturn([10 => 'spb']); \Brain\Monkey\Functions\expect('get_field')->never(); self::assertSame('', ShortcodeDmr::render(['id' => 42])); } public function test_render_returns_empty_when_term_lookup_fails(): void { $_SERVER['REQUEST_URI'] = '/tyumen/'; \Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']); \Brain\Monkey\Functions\when('get_term_by')->justReturn(false); \Brain\Monkey\Functions\expect('get_field')->never(); self::assertSame('', ShortcodeDmr::render(['id' => 42])); } public function test_render_returns_empty_when_rows_are_empty(): void { $_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\expect('get_field')->once()->andReturn([]); self::assertSame('', ShortcodeDmr::render(['id' => 42])); } public function test_render_returns_empty_when_no_row_matches(): void { $_SERVER['REQUEST_URI'] = '/tyumen/'; \Brain\Monkey\Functions\when('get_terms')->justReturn([10 => 'spb', 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\expect('get_field') ->once() ->andReturn([['town' => 10, 'text' => 'Только СПб']]); self::assertSame('', ShortcodeDmr::render(['id' => 42])); } ``` - [ ] **Step 14.2: Run — expect all 5 new tests pass** ```bash vendor/bin/phpunit tests/ShortcodeDmrTest.php ``` Expected: OK (8 tests, ≥8 assertions). - [ ] **Step 14.3: Commit** ```bash git add tests/ShortcodeDmrTest.php git commit -m "test(wpmc S3): ShortcodeDmr::render edge-case coverage" ``` --- ## Task 15: `render` — cache test **Files:** - Modify: `tests/ShortcodeDmrTest.php` - [ ] **Step 15.1: Add cache test** Append: ```php public function test_render_caches_result_per_id_and_town(): void { $_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\expect('get_field') ->once() ->with('list_shortcode', 42) ->andReturn([['town' => 11, 'text' => 'Тюмень-текст']]); ShortcodeDmr::render(['id' => 42]); ShortcodeDmr::render(['id' => 42]); ShortcodeDmr::render(['id' => 42]); self::assertTrue(true); } ``` - [ ] **Step 15.2: Run — expect PASS (cache implemented in Task 13)** ```bash vendor/bin/phpunit --filter test_render_caches_result_per_id_and_town ``` Expected: OK (1 test, 1 assertion). - [ ] **Step 15.3: Commit** ```bash git add tests/ShortcodeDmrTest.php git commit -m "test(wpmc S3): lock per-request render cache for ShortcodeDmr" ``` --- ## Task 16: `Plugin::boot()` wires `ShortcodeDmr::register()` (TDD pair) **Files:** - Create: `tests/PluginBootShortcodeDmrTest.php` - Modify: `includes/class-plugin.php` - [ ] **Step 16.1: RED — add failing test in a new file (existing PluginBootTest stays untouched)** `tests/PluginBootShortcodeDmrTest.php`: ```php getProperty('booted'); $prop->setValue($plugin, false); $plugin->boot(); self::assertNotFalse( has_action('init', ['WPMultiCity\\ShortcodeDmr', 'do_register']), 'boot() must register ShortcodeDmr on init' ); } } ``` - [ ] **Step 16.2: Run — expect FAIL** ```bash vendor/bin/phpunit --filter "PluginBootShortcodeDmrTest::test_boot_attaches_shortcode_dmr_init_action" ``` Expected: FAIL — `has_action` returns false because `boot()` doesn't call `ShortcodeDmr::register()` yet. - [ ] **Step 16.3: GREEN — add register call to Plugin::boot()** Open `includes/class-plugin.php`. Inside `boot()`, after the existing three `register()` calls, add `ShortcodeDmr::register();` so the final block looks like: ```php Taxonomy::register(); PostType::register(); FieldGroup::register(); ShortcodeDmr::register(); ``` - [ ] **Step 16.4: Run — expect PASS** ```bash vendor/bin/phpunit --filter "PluginBootShortcodeDmrTest::test_boot_attaches_shortcode_dmr_init_action" ``` Expected: OK (1 test, 1 assertion). - [ ] **Step 16.5: Commit** ```bash git add tests/PluginBootShortcodeDmrTest.php includes/class-plugin.php git commit -m "feat(wpmc S3): Plugin::boot() wires ShortcodeDmr" ``` --- ## Task 17: Procedural `wpmc-functions.php` + main file include **Files:** - Create: `includes/wpmc-functions.php` - Modify: `wp-multi-city.php` Procedural shims are not strictly tested by themselves — they're one-line passthroughs. But we add a smoke test in the existing HelpersTest to confirm the file loads and `wpmc_current_town()` returns the same value as `Helpers::current_town()`. - [ ] **Step 17.1: Create wpmc-functions.php** `includes/wpmc-functions.php`: ```php