Files
wp-multi-city/docs/superpowers/plans/2026-05-12-s3-shortcode-dmr-helpers.md
T
2026-05-12 19:53:49 +05:00

39 KiB
Raw Blame History

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
declare(strict_types=1);

namespace WPMultiCity;

final class Helpers
{
    private static ?string $current_town_memo = null;
    private static bool $current_town_resolved = false;
    private static array $term_cache = [];

    public static function reset_cache(): void
    {
        self::$current_town_memo = null;
        self::$current_town_resolved = false;
        self::$term_cache = [];
    }
}
  • Step 1.2: Wire reset into TestCase::setUp

Open tests/TestCase.php and add a Helpers::reset_cache() call after Monkey\Functions\stubTranslationFunctions(). The full file becomes:

<?php
declare(strict_types=1);

namespace WPMultiCity\Tests;

use Brain\Monkey;
use PHPUnit\Framework\TestCase as BaseTestCase;
use WPMultiCity\Helpers;

abstract class TestCase extends BaseTestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        Monkey\setUp();
        Monkey\Functions\stubTranslationFunctions();
        Helpers::reset_cache();
    }

    protected function tearDown(): void
    {
        Monkey\tearDown();
        parent::tearDown();
    }
}
  • Step 1.3: Run full suite — must still be green (9 tests)
vendor/bin/phpunit

Expected: OK (9 tests, 43 assertions).

  • Step 1.4: Commit
git add includes/class-helpers.php tests/TestCase.php
git commit -m "feat(wpmc S3): Helpers scaffold + reset_cache wiring"

Task 2: WP_Term test stub + Helpers::get_term_by_slug RED test

Files:

  • Modify: tests/bootstrap.php
  • Create: tests/HelpersTest.php

WP_Term is a WordPress core class that's not available in unit-test context. We add a minimal stub class in tests/bootstrap.php so test code can do new \WP_Term() naturally.

  • Step 2.1: Add WP_Term stub to tests/bootstrap.php

Open tests/bootstrap.php and append (after the existing autoloader+register_autoloader lines):

if (!class_exists('WP_Term', false)) {
    eval('class WP_Term { public int $term_id = 0; public string $slug = ""; public string $taxonomy = ""; }');
}

Final 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();

if (!class_exists('WP_Term', false)) {
    eval('class WP_Term { public int $term_id = 0; public string $slug = ""; public string $taxonomy = ""; }');
}
  • Step 2.2: Write failing test

tests/HelpersTest.php:

<?php
declare(strict_types=1);

namespace WPMultiCity\Tests;

use WPMultiCity\Helpers;

final class HelpersTest extends TestCase
{
    public function test_get_term_by_slug_returns_wp_term_when_found(): void
    {
        $term = new \WP_Term();
        $term->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)
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):

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

    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
vendor/bin/phpunit tests/HelpersTest.php

Expected: OK (3 tests, ≥4 assertions).

  • Step 3.5: Commit
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:

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

    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
vendor/bin/phpunit --filter test_current_town_returns_slug_when_uri_matches_existing_town

Expected: OK (1 test, 1 assertion).

  • Step 5.3: Commit
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:

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

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

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

    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
vendor/bin/phpunit --filter alt_get_field

Expected: OK (4 tests, ≥4 assertions).

  • Step 8.5: Commit
git add includes/class-helpers.php tests/HelpersTest.php
git commit -m "feat(wpmc S3): Helpers::alt_get_field with do_shortcode + filter"

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:

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

    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
vendor/bin/phpunit --filter test_remove_domain_link_handles_various_url_shapes

Expected: OK (1 test, 5 assertions).

  • Step 9.5: Commit
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
declare(strict_types=1);

namespace WPMultiCity\Tests;

use WPMultiCity\ShortcodeDmr;

final class ShortcodeDmrTest extends TestCase
{
    public function test_register_attaches_init_action(): void
    {
        ShortcodeDmr::register();

        self::assertNotFalse(
            has_action('init', [ShortcodeDmr::class, 'do_register']),
            'ShortcodeDmr::register() must hook init'
        );
    }
}
  • Step 10.2: Run — expect FAIL (class not found)
vendor/bin/phpunit --filter "ShortcodeDmrTest::test_register_attaches_init_action"

Expected: Error — Class "WPMultiCity\ShortcodeDmr" not found.

  • Step 10.3: GREEN — create class

includes/class-shortcode-dmr.php:

<?php
declare(strict_types=1);

namespace WPMultiCity;

final class ShortcodeDmr
{
    public const SHORTCODE = 'shortcode_dmr';

    private static array $render_cache = [];

    public static function reset_cache(): void
    {
        self::$render_cache = [];
    }

    public static function register(): void
    {
        \add_action('init', [self::class, 'do_register']);
    }

    public static function do_register(): void
    {
        \add_shortcode(self::SHORTCODE, [self::class, 'render']);
    }

    public static function render($atts = [], $content = null): string
    {
        // Implementation in Tasks 12-16.
        return '';
    }
}
  • Step 10.4: Wire reset into TestCase::setUp

Open tests/TestCase.php. Add ShortcodeDmr::reset_cache() after Helpers::reset_cache(). The file becomes:

<?php
declare(strict_types=1);

namespace WPMultiCity\Tests;

use Brain\Monkey;
use PHPUnit\Framework\TestCase as BaseTestCase;
use WPMultiCity\Helpers;
use WPMultiCity\ShortcodeDmr;

abstract class TestCase extends BaseTestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        Monkey\setUp();
        Monkey\Functions\stubTranslationFunctions();
        Helpers::reset_cache();
        ShortcodeDmr::reset_cache();
    }

    protected function tearDown(): void
    {
        Monkey\tearDown();
        parent::tearDown();
    }
}
  • Step 10.5: Run — expect PASS
vendor/bin/phpunit --filter "ShortcodeDmrTest::test_register_attaches_init_action"

Expected: OK (1 test, 1 assertion).

  • Step 10.6: Commit
git add tests/ShortcodeDmrTest.php tests/TestCase.php includes/class-shortcode-dmr.php
git commit -m "feat(wpmc S3): ShortcodeDmr scaffold + register init hook"

Task 11: ShortcodeDmr::do_register calls add_shortcode (TDD pair)

Files:

  • Modify: tests/ShortcodeDmrTest.php

  • Step 11.1: Add failing test

Append to ShortcodeDmrTest:

    public function test_do_register_registers_shortcode(): void
    {
        \Brain\Monkey\Functions\expect('add_shortcode')
            ->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)
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)
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:

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

    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
vendor/bin/phpunit --filter test_render_returns_text_when_row_matches_current_town

Expected: OK (1 test, 1 assertion).

  • Step 13.3: Commit
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:

    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
vendor/bin/phpunit tests/ShortcodeDmrTest.php

Expected: OK (8 tests, ≥8 assertions).

  • Step 14.3: Commit
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:

    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)
vendor/bin/phpunit --filter test_render_caches_result_per_id_and_town

Expected: OK (1 test, 1 assertion).

  • Step 15.3: Commit
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
declare(strict_types=1);

namespace WPMultiCity\Tests;

use WPMultiCity\Plugin;

final class PluginBootShortcodeDmrTest extends TestCase
{
    public function test_boot_attaches_shortcode_dmr_init_action(): void
    {
        if (!class_exists('ACF', false)) {
            eval('class ACF {}');
        }

        $plugin = Plugin::instance();
        $reflection = new \ReflectionClass($plugin);
        $prop = $reflection->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
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:

        Taxonomy::register();
        PostType::register();
        FieldGroup::register();
        ShortcodeDmr::register();
  • Step 16.4: Run — expect PASS
vendor/bin/phpunit --filter "PluginBootShortcodeDmrTest::test_boot_attaches_shortcode_dmr_init_action"

Expected: OK (1 test, 1 assertion).

  • Step 16.5: Commit
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
declare(strict_types=1);

use WPMultiCity\Helpers;

if (!function_exists('wpmc_current_town')) {
    function wpmc_current_town(): ?string
    {
        return Helpers::current_town();
    }
}

if (!function_exists('wpmc_get_term_by_slug')) {
    function wpmc_get_term_by_slug(string $slug, string $taxonomy = 'town'): ?\WP_Term
    {
        return Helpers::get_term_by_slug($slug, $taxonomy);
    }
}

if (!function_exists('wpmc_alt_get_field')) {
    function wpmc_alt_get_field(string $selector, $post_id = false, bool $format_value = true)
    {
        return Helpers::alt_get_field($selector, $post_id, $format_value);
    }
}

if (!function_exists('wpmc_remove_domain_link')) {
    function wpmc_remove_domain_link(string $url): string
    {
        return Helpers::remove_domain_link($url);
    }
}
  • Step 17.2: Modify wp-multi-city.php to require the file

Open wp-multi-city.php. After the register_autoloader() line (line 23) and before the register_activation_hook line, add:

require_once WPMC_PLUGIN_DIR . 'includes/wpmc-functions.php';

So the relevant block reads:

require_once WPMC_PLUGIN_DIR . 'includes/class-plugin.php';
\WPMultiCity\Plugin::register_autoloader();
require_once WPMC_PLUGIN_DIR . 'includes/wpmc-functions.php';

register_activation_hook(__FILE__, ['\\WPMultiCity\\Activator', 'activate']);
  • Step 17.3: Add procedural-API smoke test

Append to tests/HelpersTest.php:

    public function test_wpmc_procedural_functions_proxy_to_helpers(): void
    {
        require_once dirname(__DIR__) . '/includes/wpmc-functions.php';

        self::assertTrue(function_exists('wpmc_current_town'));
        self::assertTrue(function_exists('wpmc_get_term_by_slug'));
        self::assertTrue(function_exists('wpmc_alt_get_field'));
        self::assertTrue(function_exists('wpmc_remove_domain_link'));

        // remove_domain_link is pure (no WP function dependencies) — exercise the proxy.
        self::assertSame('/foo/', wpmc_remove_domain_link('https://site.com/foo/'));
    }
  • Step 17.4: Run full suite
vendor/bin/phpunit

Expected: OK (≥21 tests, no risky/warnings). Count varies depending on which subagent reordered tests; the important number is the assertion count growing monotonically from 43 (S2) to whatever S3 contributes.

  • Step 17.5: PHP lint the new file
php -l includes/wpmc-functions.php

Expected: No syntax errors detected.

  • Step 17.6: Commit
git add includes/wpmc-functions.php wp-multi-city.php tests/HelpersTest.php
git commit -m "feat(wpmc S3): procedural wpmc_* API + smoke test"

Task 18: Update README roadmap

Files:

  • Modify: README.md

  • Step 18.1: Mark S3 done

In README.md, change the S3 row from:

| S3 | `[shortcode_dmr id=N]` + хелперы (`current_town`, `_alt_get_field`, `remove_domain_link`) | cp-arq | open |

to:

| S3 | `[shortcode_dmr id=N]` + хелперы (`current_town`, `_alt_get_field`, `remove_domain_link`) | cp-arq | done |
  • Step 18.2: Commit
git add README.md
git commit -m "docs(wpmc): mark S3 done in README roadmap"

Task 19: Final verification

Files: none

  • Step 19.1: Full PHPUnit run
vendor/bin/phpunit

Expected: OK with all tests green, no risky/no warnings. Count expectation: 9 (S2) + 27 (S3) overlaps = approximately 27 new tests for S3 → ~36 total, ≥90 assertions.

  • Step 19.2: PHP lint every new/modified PHP file
for f in includes/class-helpers.php includes/class-shortcode-dmr.php includes/wpmc-functions.php includes/class-plugin.php wp-multi-city.php tests/HelpersTest.php tests/ShortcodeDmrTest.php tests/PluginBootShortcodeDmrTest.php tests/TestCase.php; do
  php -l "$f"
done

Expected: No syntax errors detected × 9.

  • Step 19.3: Working tree clean
git status

Expected: nothing to commit, working tree clean. If a Yandex.Disk conflict copy appears (* (2).php), restore from HEAD as documented in S2's recovery note.

  • Step 19.4: Git log review
git log --oneline | head -25

Expected to see the chain of S3 commits in order: spec → Helpers scaffold → get_term_by_slug → current_town → alt_get_field → remove_domain_link → ShortcodeDmr scaffold → register → render TDD pair → render edge cases → render cache lock → Plugin::boot wire → procedural API → README.

  • Step 19.5: Close beads task
bd close cp-arq --reason "S3 done: wpmc_current_town/_get_term_by_slug/_alt_get_field/_remove_domain_link + [shortcode_dmr id=N] handler. ~27 new Brain Monkey tests. Plugin::boot wires ShortcodeDmr. Procedural API at wpmc-functions.php."

Expected: cp-arq closed. bd ready now lists cp-83m (S4) under epic cp-cw4.


Acceptance map (spec → tasks)

Spec requirement Task(s)
wpmc_current_town() returns slug from URI match 4, 5
wpmc_current_town() fallback to wpmc_main_town_slug filter 6
wpmc_current_town() wpmc_current_town_slug final filter 6
wpmc_current_town() per-request memoization 6
wpmc_get_term_by_slug() with cache 2, 3
[shortcode_dmr id=N] returns text for matching row 12, 13
[shortcode_dmr id=N] returns '' on edge cases 14
[shortcode_dmr id=N] per-request cache 15
wpmc_alt_get_field() do_shortcode for strings, identity for arrays 7, 8
wpmc_alt_get_field() wpmc_alt_get_field_post_id filter 8
wpmc_alt_get_field() graceful when get_field missing 8
wpmc_remove_domain_link() strips scheme+host, preserves query+fragment 9
wpmc_remove_domain_link() no-op for mailto/tel/relative 9
Plugin::boot() wires ShortcodeDmr::register() 16
Procedural API loaded unconditionally from main file 17
README roadmap updated 18
Full green suite + lint + close beads 19