feat: lit-divi-insert prüft Schleifen-Inhalte im Kontext des Ziels; neue Schleife plus Inhalt in einem Aufruf fail closed (X3)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 20:51:42 +02:00
co-authored by Claude Sonnet 5.5
parent 2f484b77ae
commit 249bc7b932
4 changed files with 254 additions and 4 deletions
@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use WP_Error;
/**
* Ruling X3: lit/divi-insert, lit/divi-update and lit/divi-move use the loop scope of the node in the tree
* (LoopContext). The page: s0.r0.c0 runs a post loop (m0 shows loop_post_title, m1 is plain text, m2 runs its own
* term loop and shows loop_term_name), s0.r0.c1 a term loop, s0.r0.c2 no loop.
*/
final class DiviLoopWriteTest extends DiviWriteAbilityTestCase
{
private const POST_LOOP = ['enable' => 'on', 'queryType' => 'post_types'];
private const TERM_LOOP = ['enable' => 'on', 'queryType' => 'post_taxonomies'];
protected function setUp(): void
{
parent::setUp();
$this->original = self::page();
$this->post->post_content = $this->original;
$registry = self::registry();
$this->writer = DiviWriterFixture::writer(
$this->saved,
registry: new DynamicContentMap(static fn (int $postId): array => $registry)
);
}
/** DiviFixtures' registry plus the loop options these tests name (Divi 5.15.1 groups and fields). */
private static function registry(): array
{
$field = static fn (string $type): array => ['label' => 'x', 'type' => $type, 'default' => ''];
$common = ['before' => $field('text'), 'after' => $field('text'), 'loop_position' => $field('text')];
$option = static fn (string $id, string $group, array $fields): array => [
'id' => $id, 'label' => $id, 'type' => 'text', 'custom' => false, 'group' => $group, 'fields' => $fields,
];
return DiviFixtures::dynamicContentRegistry() + [
'loop_post_date' => $option('loop_post_date', 'Loop', $common + [
'date_format' => $field('select'), 'custom_date_format' => $field('text'),
]),
'loop_term_name' => $option('loop_term_name', 'Loop Terms', $common),
];
}
/** @param array<string, mixed> $attrs */
private static function block(string $name, array $attrs, string $inner = ''): string
{
$json = (string) json_encode($attrs + ['builderVersion' => '5.2.1'], JSON_UNESCAPED_UNICODE);
return sprintf('<!-- wp:divi/%s %s -->%s<!-- /wp:divi/%s -->', $name, $json, $inner, $name);
}
/** @param array<string, string> $loop */
private static function loop(array $loop): array
{
return ['module' => ['advanced' => ['loop' => ['desktop' => ['value' => $loop]]]]];
}
private static function text(string $value): string
{
return self::block('text', ['content' => ['innerContent' => ['desktop' => ['value' => $value]]]]);
}
private static function page(): string
{
$title = VariableRef::serialize('content', 'loop_post_title', ['before' => '', 'after' => '']);
$term = VariableRef::serialize('content', 'loop_term_name', ['before' => '', 'after' => '']);
$own = self::block('text', self::loop(self::TERM_LOOP) + [
'content' => ['innerContent' => ['desktop' => ['value' => $term]]],
]);
return '<!-- wp:divi/placeholder -->' . self::block('section', [], self::block('row', [], implode('', [
self::block('column', self::loop(self::POST_LOOP), self::text($title) . self::text('<p>Fest</p>') . $own),
self::block('column', self::loop(self::TERM_LOOP), self::text('<p>Term</p>')),
self::block('column', [], self::text('<p>Ohne</p>')),
]))) . '<!-- /wp:divi/placeholder -->';
}
/** @param array<string, mixed> $input */
private function insertInto(string $parent, array $input): mixed
{
return $this->insert()->execute(
['post_id' => 7, 'tree_rev' => $this->currentRev(), 'parent' => $parent] + $input
);
}
/** @param list<array<string, mixed>> $changes */
private function changeAll(array $changes): mixed
{
return $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->currentRev(), 'changes' => $changes]);
}
/** @param array<string, mixed> $input */
private function moveNode(array $input): mixed
{
return $this->move()->execute(['post_id' => 7, 'tree_rev' => $this->currentRev()] + $input);
}
/** tree_rev of what the page holds now (the saver puts every saved content on the post). */
private function currentRev(): string
{
return Rev::of((string) $this->post->post_content);
}
private static function problems(mixed $error): string
{
self::assertInstanceOf(WP_Error::class, $error);
return implode(' | ', self::errorFields($error));
}
public function testInsertUsesTheLoopOfTheTargetParent(): void
{
$result = $this->insertInto('s0.r0.c0', ['nodes' => [
['type' => 'text', 'content' => '{{loop:post_date}}'],
['type' => 'text', 'content' => '{{loop:post_title}}'],
]]);
self::assertTrue($result['ok'], (string) json_encode($result));
$date = VariableRef::serialize('content', 'loop_post_date', ['before' => '', 'after' => '']);
self::assertStringContainsString(str_replace('"', '\\' . 'u0022', $date), $this->saved[0]);
$term = $this->insertInto('s0.r0.c1', ['nodes' => [['type' => 'text', 'content' => '{{loop:term_name}}']]]);
self::assertTrue($term['ok'], (string) json_encode($term));
}
public function testInsertRefusesLoopContentOutsideALoopOrOfAnotherKind(): void
{
$outside = $this->insertInto('s0.r0.c2', ['nodes' => [['type' => 'text', 'content' => '{{loop:post_date}}']]]);
self::assertStringContainsString('braucht eine Divi-Schleife', self::problems($outside));
$term = $this->insertInto('s0.r0.c1', ['nodes' => [['type' => 'text', 'content' => '{{loop:post_date}}']]]);
$wrongKind = self::problems($term);
self::assertStringContainsString('passt nicht zur Schleife dieses Elements (Typ: Terme)', $wrongKind);
$topLevel = $this->insert()->execute([
'post_id' => 7,
'tree_rev' => $this->currentRev(),
'nodes' => [['type' => 'section', 'modules' => [['type' => 'text', 'content' => '{{loop:post_date}}']]]],
]);
self::assertStringContainsString('braucht eine Divi-Schleife', self::problems($topLevel));
self::assertSame([], $this->saved);
}
/**
* Ruling X3, fail closed: a loop a new node of this call switches on counts only once it is saved – loop
* content in the same call is refused even where the parent's loop would let it pass (a term loop inside the
* post loop c0 would otherwise get post content: Divi reads the post with the term's ID).
*/
public function testANewLoopInTheSameCallIsRefusedWithLoopContent(): void
{
$ownLoop = ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]];
foreach (['s0.r0.c0' => '{{loop:post_title}}', 's0.r0.c2' => '{{loop:term_name}}'] as $parent => $content) {
$refused = $this->insertInto($parent, ['nodes' => [
['type' => 'text', 'attrs' => $ownLoop, 'content' => $content],
]]);
self::assertStringContainsString('erst nach dem Speichern', self::problems($refused), $parent);
}
self::assertSame([], $this->saved);
$withoutLoopContent = $this->insertInto('s0.r0.c2', ['nodes' => [
['type' => 'text', 'attrs' => $ownLoop, 'content' => '<p>Liste</p>'],
]]);
self::assertTrue($withoutLoopContent['ok'], (string) json_encode($withoutLoopContent));
}
}