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

1391 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<?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
<?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)**
```bash
vendor/bin/phpunit
```
Expected: OK (9 tests, 43 assertions).
- [ ] **Step 1.4: Commit**
```bash
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):
```php
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
<?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
<?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)**
```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
<?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)**
```bash
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
<?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
<?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**
```bash
vendor/bin/phpunit --filter "ShortcodeDmrTest::test_register_attaches_init_action"
```
Expected: OK (1 test, 1 assertion).
- [ ] **Step 10.6: Commit**
```bash
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:
```php
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)**
```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
<?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**
```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
<?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:
```php
require_once WPMC_PLUGIN_DIR . 'includes/wpmc-functions.php';
```
So the relevant block reads:
```php
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`:
```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**
```bash
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**
```bash
php -l includes/wpmc-functions.php
```
Expected: `No syntax errors detected`.
- [ ] **Step 17.6: Commit**
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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 |