Agent seam: the
// render-time tagger (Agent\TagBlocks) and the save-path re-derivation
// (QuickEdit\SaveController::findBlock) are two hand-maintained twins of one
// preorder-numbering convention. If they drift, the client clicks block N and
// the server saves into a *different* block N — silent content corruption.
//
// Every other SaveControllerTest case hardcodes blockId (a human's manual
// count), so none of them would catch the two walks diverging. These tests
// instead run real content through the genuine the_content filter chain with
// TagBlocks active, read the actual data-extendify-agent-block-id the tagger
// emitted onto the rendered DOM, then feed THAT id back into handleSave — the
// only way to assert the two numberings stay in lockstep, including across an
// $ignored (loop) subtree where a divergent skip would shift every later id.
class TagBlocksRoundTripTest extends WP_UnitTestCase
{
public function setUp(): void
{
parent::setUp();
$this->loginAsAdmin();
$this->resetRegistry();
Registry::init();
// WP_UnitTestCase restores $wp_filter per test, so this registers
// TagBlocks' the_content/render_block filters exactly once per case —
// no enterScope stacking that would push scope depth past 1.
unset($GLOBALS['extendify_agent_scope']);
TagBlocks::init();
}
public function tearDown(): void
{
$this->resetRegistry();
unset($GLOBALS['extendify_agent_scope']);
parent::tearDown();
}
// The core lockstep pin: an editable block sitting AFTER an $ignored loop
// must get the same id from the render-time tagger and the save-path walk.
// The loop's inner heading is counted by neither side — if either skip
// diverged, "bravo" would tag/resolve at a different id and the save would
// land on the wrong block (or on the loop's inner block).
public function test_block_id_round_trips_across_an_ignored_loop()
{
// 2 published posts so the post-template actually renders its inner
// heading per item at render time — exercising the "skip a subtree that
// renders once per loop item" path, not just an empty query.
self::factory()->post->create(['post_status' => 'publish']);
self::factory()->post->create(['post_status' => 'publish']);
$content = '
alpha
'
. ''
. ''
. ''
. '
inside-loop
'
. ''
. ''
. ''
. 'bravo
';
$postId = self::factory()->post->create(['post_content' => $content]);
$rendered = $this->renderTagged($postId);
// The tagger skipped the whole core/query subtree, so the loop's inner
// heading carries no id at all.
$this->assertNull(
$this->idForText($rendered, 'h2', 'inside-loop'),
'an $ignored loop subtree must not be tagged'
);
// Feed the id the tagger actually emitted onto "bravo" back to save.
// No fingerprint on purpose: that isolates the count path so a
// one-sided skip drift fails loudly (wrong-type 409 / wrong block)
// instead of being silently recovered by fingerprint identity — the
// recovery net is already pinned by SaveControllerTest's fingerprint
// cases; this test exists to pin the numbering itself.
$bravoId = $this->idForText($rendered, 'h2', 'bravo');
$this->assertNotNull($bravoId, 'the block after the loop must be tagged');
$res = SaveController::handleSave($this->jsonRequest([
'source' => ['kind' => 'post', 'id' => $postId],
'blockId' => $bravoId,
'blockType' => 'core/heading',
'patches' => [['fieldKey' => 'content', 'value' => 'bravo-edited']],
]));
$this->assertSame(200, $res->get_status());
$stored = get_post($postId)->post_content;
// The save resolved the SAME block the tagger pointed at: bravo edited,
// its siblings (and the loop template) untouched.
$this->assertStringContainsString('bravo-edited
', $stored);
$this->assertStringContainsString('alpha
', $stored);
$this->assertStringContainsString('inside-loop
', $stored);
}
// The other half of the walk: nested (non-ignored) children ARE counted in
// preorder by both sides. A heading inside a group must round-trip to the
// group's child, not the group wrapper or a sibling.
public function test_block_id_round_trips_through_nested_groups()
{
$content = 'outer
'
. ''
. ''
. '
nested
'
. ''
. ''
. 'after
';
$postId = self::factory()->post->create(['post_content' => $content]);
$rendered = $this->renderTagged($postId);
$nestedId = $this->idForText($rendered, 'h2', 'nested');
$this->assertNotNull($nestedId, 'a nested non-ignored block must be tagged');
$res = SaveController::handleSave($this->jsonRequest([
'source' => ['kind' => 'post', 'id' => $postId],
'blockId' => $nestedId,
'blockType' => 'core/heading',
'patches' => [['fieldKey' => 'content', 'value' => 'nested-edited']],
]));
$this->assertSame(200, $res->get_status());
$stored = get_post($postId)->post_content;
$this->assertStringContainsString('nested-edited
', $stored);
$this->assertStringContainsString('outer
', $stored);
$this->assertStringContainsString('after
', $stored);
}
// Render the post's content through the same the_content chain a live page
// runs, with TagBlocks active, and return the tagged HTML. Mirrors
// SaveController::renderBlockHtml's global-$post snapshot/restore so a
// dangling global can't leak into other tests.
private function renderTagged(int $postId): string
{
$post = get_post($postId);
$previousPost = $GLOBALS['post'] ?? null;
$GLOBALS['post'] = $post;
setup_postdata($post);
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core WP filter
$html = (string) apply_filters('the_content', $post->post_content);
wp_reset_postdata();
$GLOBALS['post'] = $previousPost;
return $html;
}
// The id the tagger appended to the first tag of $tag whose text is $text,
// or null when that node carries no id (e.g. it sat inside an $ignored
// subtree). Reads the real emitted attribute — the whole point of the pin.
private function idForText(string $html, string $tag, string $text): ?int
{
$dom = new \DOMDocument();
$previous = libxml_use_internal_errors(true);
$dom->loadHTML('' . $html . '
');
libxml_clear_errors();
libxml_use_internal_errors($previous);
$xpath = new \DOMXPath($dom);
foreach ($xpath->query("//{$tag}") as $node) {
if (trim($node->textContent) === $text) {
$id = $node->getAttribute('data-extendify-agent-block-id');
return $id === '' ? null : (int) $id;
}
}
return null;
}
private function jsonRequest(array $body): \WP_REST_Request
{
$req = new \WP_REST_Request('POST', '/extendify/v1/quick-edit/save');
$req->set_header('Content-Type', 'application/json');
$req->set_body(wp_json_encode($body));
return $req;
}
private function loginAsAdmin(): void
{
$admin = self::factory()->user->create(['role' => 'administrator']);
wp_set_current_user($admin);
}
private function resetRegistry(): void
{
$reflection = new ReflectionClass(Registry::class);
$prop = $reflection->getProperty('schemas');
$prop->setAccessible(true);
$prop->setValue(null, []);
}
}