39 KiB
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"
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:
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 |