Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php
T

377 lines
17 KiB
PHP
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.
<?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],
]]);
// c2 has no loop: the composer refuses the loop content itself (the call is refused either way).
self::assertStringContainsString(
$parent === 's0.r0.c0' ? 'erst nach dem Speichern' : 'braucht eine Divi-Schleife',
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));
}
/** Ruling X3: one Composer per loop scope – changes in a post loop and a term loop in one call. */
public function testUpdateChecksEveryChangeInItsOwnLoop(): void
{
$result = $this->changeAll([
['id' => 's0.r0.c0.m1', 'set' => ['content' => '{{loop:post_date}}']],
['id' => 's0.r0.c1.m0', 'set' => ['content' => '{{loop:term_name}}']],
['id' => 's0.r0.c2.m0', 'set' => ['content' => '<p>Neu</p>']],
]);
self::assertTrue($result['ok'], (string) json_encode($result));
self::assertStringContainsString('loop_term_name', $this->saved[0]);
$refused = $this->changeAll([
['id' => 's0.r0.c0.m1', 'set' => ['content' => '{{loop:term_name}}']],
['id' => 's0.r0.c2.m0', 'set' => ['content' => '{{loop:post_date}}']],
]);
$problems = self::errorFields($refused);
self::assertStringContainsString('Typ: Beiträge', $problems['changes[0].set.content'] ?? '');
self::assertStringContainsString('braucht eine Divi-Schleife', $problems['changes[1].set.content'] ?? '');
}
/**
* Ruling X3: the scope of a change takes the loop attrs this call gives the node or an ancestor – a loop
* switched on admits its content, a loop switched to another kind refuses the old kind's content.
*/
public function testUpdateUsesTheLoopsThisCallSets(): void
{
$switchedOn = $this->changeAll([
['id' => 's0.r0.c2', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]],
['id' => 's0.r0.c2.m0', 'set' => ['content' => '{{loop:term_name}}']],
]);
self::assertTrue($switchedOn['ok'], (string) json_encode($switchedOn));
$own = $this->changeAll([[
'id' => 's0.r0.c0.m1',
'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]],
'set' => ['content' => '{{loop:term_name}}'],
]]);
self::assertTrue($own['ok'], (string) json_encode($own));
$switchedKind = $this->changeAll([
['id' => 's0.r0.c0', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]],
['id' => 's0.r0.c0.m0', 'set' => ['content' => '{{loop:post_date}}']],
]);
$problems = self::errorFields($switchedKind);
self::assertStringContainsString('Typ: Terme', $problems['changes[1].set.content'] ?? '');
self::assertCount(2, $this->saved);
}
public function testMovingLoopContentOutOfItsLoopWarns(): void
{
$out = $this->moveNode([
'id' => 's0.r0.c0.m0', 'parent' => 's0.r0.c2',
]);
self::assertTrue($out['ok'], 'Divi allows the move (A11): a warning, no refusal');
$warnings = implode(' | ', $out['warnings']);
self::assertStringContainsString('loop_post_title', $warnings);
self::assertStringContainsString('außerhalb jeder Divi-Schleife', $warnings);
$other = $this->moveNode([
'id' => 's0.r0.c2.m1', 'parent' => 's0.r0.c1',
]);
self::assertStringContainsString('Typ: Terme (vorher: keine Schleife)', implode(' | ', $other['warnings']));
}
public function testMovesThatKeepTheLoopDoNotWarn(): void
{
// Path IDs are positions: after this move c0 holds [Fest, title, own loop].
$withinLoop = $this->moveNode([
'id' => 's0.r0.c0.m0', 'after' => 's0.r0.c0.m1',
]);
// m0 is the plain text now: nothing to warn about when it leaves the loop.
$plain = $this->moveNode([
'id' => 's0.r0.c0.m0', 'parent' => 's0.r0.c2',
]);
// m1 is the module with its own term loop now: it takes the loop along.
$loopItself = $this->moveNode([
'id' => 's0.r0.c0.m1', 'parent' => 's0.r0.c2',
]);
foreach ([$withinLoop, $plain, $loopItself] as $result) {
self::assertTrue($result['ok'], (string) json_encode($result));
self::assertStringNotContainsString('Schleife', implode(' | ', $result['warnings']));
}
}
/**
* Fix round 1 (critical): the fail-closed decision reads the composed blocks, not the client's text. The writers
* strip control characters and trim values (S35), so these forms become canonical loop content.
*
* @return array<string, array{0: string}>
*/
public static function obfuscatedLoopContent(): array
{
$ref = VariableRef::serialize('content', 'loop_post_title', ['before' => '', 'after' => '']);
return [
'control char inside the placeholder' => ["{\x01{loop:post_title}}"],
'leading space before the full form' => [' ' . $ref],
'leading control char before the full form' => ["\x01" . $ref],
'plain placeholder' => ['{{loop:post_title}}'],
];
}
/** @dataProvider obfuscatedLoopContent */
public function testANewLoopWithObfuscatedLoopContentIsRefused(string $content): void
{
$refused = $this->insertInto('s0.r0.c0', ['nodes' => [[
'type' => 'text',
'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]],
'content' => $content,
]]]);
// Whichever check catches it (fail closed on the composed blocks, or the writer's own D9 refusal): refused.
self::assertNotSame('', self::problems($refused));
self::assertSame([], $this->saved);
}
/** Loop content through attrs and in a sibling of the loop node: the whole call fails closed (ruling X3). */
public function testLoopContentViaAttrsOrInASiblingOfANewLoopIsRefused(): void
{
$ownLoop = ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]];
$ref = VariableRef::serialize('content', 'loop_post_title', ['before' => '', 'after' => '']);
$viaAttrs = $this->insertInto('s0.r0.c0', ['nodes' => [[
'type' => 'text',
'attrs' => $ownLoop + ['content.innerContent' => ['desktop' => $ref]],
]]]);
self::assertStringContainsString('erst nach dem Speichern', self::problems($viaAttrs));
$sibling = $this->insertInto('s0.r0.c0', ['nodes' => [
['type' => 'text', 'attrs' => $ownLoop, 'content' => '<p>Liste</p>'],
['type' => 'text', 'content' => "{\x01{loop:post_title}}"],
]]);
self::assertStringContainsString('erst nach dem Speichern', self::problems($sibling));
self::assertSame([], $this->saved);
}
/** Loop content in a descendant of the new loop node (a section with a module) is refused, nothing saved. */
public function testLoopContentBelowANewLoopContainerIsRefused(): void
{
$refused = $this->insert()->execute([
'post_id' => 7,
'tree_rev' => $this->currentRev(),
'nodes' => [[
'type' => 'section',
'attrs' => ['module.advanced.loop' => ['desktop' => self::POST_LOOP]],
'modules' => [['type' => 'text', 'content' => "{\x01{loop:post_title}}"]],
]],
]);
self::assertInstanceOf(WP_Error::class, $refused);
self::assertSame([], $this->saved);
}
/** The update path decides on the composed attrs too: a control-char placeholder gets the scope of its node. */
public function testUpdateChecksObfuscatedLoopContentInTheNodesScope(): void
{
$ok = $this->changeAll([['id' => 's0.r0.c0.m1', 'set' => ['content' => "{\x01{loop:post_date}}"]]]);
self::assertTrue($ok['ok'], (string) json_encode($ok));
$refused = $this->changeAll([['id' => 's0.r0.c2.m0', 'set' => ['content' => "{\x01{loop:post_date}}"]]]);
self::assertStringContainsString('braucht eine Divi-Schleife', self::problems($refused));
}
/** Fix round 1 (important): a loop whose kind changes under unchanged loop_* children warns, as move does. */
public function testUpdateWarnsWhenTheLoopKindChangesUnderUnchangedChildren(): void
{
$result = $this->changeAll([
['id' => 's0.r0.c0', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]],
]);
self::assertTrue($result['ok'], (string) json_encode($result));
$warnings = implode(' | ', $result['warnings']);
self::assertStringContainsString('s0.r0.c0:', $warnings);
self::assertStringContainsString('loop_post_title', $warnings);
self::assertStringContainsString('Typ: Terme (vorher: Beiträge)', $warnings);
self::assertStringNotContainsString('loop_term_name', $warnings, 'a descendant with its own loop keeps it');
}
public function testUpdateSwitchingTheLoopOffWarnsAndAKeptKindDoesNot(): void
{
$same = $this->changeAll([
['id' => 's0.r0.c0', 'attrs' => ['module.advanced.loop' => ['desktop' => [
'enable' => 'on', 'queryType' => 'current_page',
]]]],
]);
self::assertTrue($same['ok'], (string) json_encode($same));
self::assertSame([], $same['warnings']);
$off = $this->changeAll([
['id' => 's0.r0.c0', 'attrs' => ['module.advanced.loop' => ['desktop' => ['enable' => 'off']]]],
]);
self::assertStringContainsString('außerhalb jeder Divi-Schleife', implode(' | ', $off['warnings']));
}
}