Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
312 lines
13 KiB
PHP
312 lines
13 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||
|
||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Document;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
|
||
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class DiviCompositionTest extends TestCase
|
||
{
|
||
/** @var list<int> */
|
||
private array $registryCalls = [];
|
||
|
||
private function composition(?DesignSystemWriter $presets = null): DiviComposition
|
||
{
|
||
$modules = Mockery::mock(DiviModules::class);
|
||
$modules->allows('catalog')->andReturn(DiviFixtures::catalog());
|
||
$modules->allows('builderVersion')->andReturn('5.2.1');
|
||
$design = Mockery::mock(DesignSystemReader::class);
|
||
$design->allows('read')->andReturn(DiviFixtures::designSystem());
|
||
$registry = new DynamicContentMap(function (int $postId): array {
|
||
$this->registryCalls[] = $postId;
|
||
|
||
return DiviFixtures::dynamicContentRegistry();
|
||
});
|
||
|
||
return new DiviComposition(
|
||
$modules,
|
||
$design,
|
||
ComposeFixture::sanitizer(),
|
||
ComposeFixture::media(),
|
||
$registry,
|
||
ComposeFixture::links(),
|
||
presetSource: $presets
|
||
);
|
||
}
|
||
|
||
/** The preset source of the live wiring: Divi's stored presets (raw format) and the option groups per module. */
|
||
private function presetSource(int $reads = 1): DesignSystemWriter
|
||
{
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->expects('presets')->times($reads)->andReturn([
|
||
'module' => ['divi/button' => ['default' => 'p-1', 'items' => [
|
||
'p-1' => ['id' => 'p-1', 'name' => 'Standard', 'moduleName' => 'divi/button'],
|
||
'p-2' => [
|
||
'id' => 'p-2',
|
||
'name' => 'CTA primär',
|
||
'moduleName' => 'divi/button',
|
||
'attrs' => ['button' => ['decoration' => ['font' => ['font' => [
|
||
'desktop' => ['value' => ['color' => '#ffffff']],
|
||
]]]]],
|
||
],
|
||
]]],
|
||
'group' => ['divi/font' => ['default' => '', 'items' => [
|
||
'g-1' => ['id' => 'g-1', 'name' => 'Fließtext groß', 'groupName' => 'divi/font'],
|
||
]]],
|
||
]);
|
||
$writer->allows('groupsOf')->andReturnUsing(
|
||
static fn (string $module): array => $module === 'divi/button'
|
||
? ['button.decoration.font.font' => 'divi/font']
|
||
: []
|
||
);
|
||
|
||
return $writer;
|
||
}
|
||
|
||
private static function siteTitle(): string
|
||
{
|
||
return VariableRef::serialize('content', 'site_title', ['before' => '', 'after' => '']);
|
||
}
|
||
|
||
public function testOneLiveMapIsSharedByTheFullAndTheShortForm(): void
|
||
{
|
||
$composition = $this->composition()->forPost(7);
|
||
|
||
// Full form (AttrWriter).
|
||
$full = $composition->attrWriter()->apply(
|
||
'divi/text',
|
||
[],
|
||
['content.innerContent' => ['desktop' => self::siteTitle()]],
|
||
'attrs'
|
||
);
|
||
self::assertSame([], $full['errors']);
|
||
self::assertSame(self::siteTitle(), AttrMap::desktop($full['attrs'], 'content.innerContent'));
|
||
|
||
// Short form through the Composer (ShortformWriter) – same map, same AttrWriter.
|
||
$short = $composition->composer()->compose(
|
||
[['type' => 'button', 'text' => self::siteTitle()]],
|
||
Block::create('divi/column'),
|
||
'nodes'
|
||
);
|
||
self::assertSame([], $short['errors']);
|
||
self::assertStringContainsString('site_title', json_encode($short['blocks'][0]->attrs(), JSON_THROW_ON_ERROR));
|
||
|
||
self::assertSame([7], $this->registryCalls, 'the registry is read once per post');
|
||
self::assertSame($composition->attrWriter(), $composition->attrWriter());
|
||
self::assertSame($composition->placeholders(), $composition->placeholders());
|
||
self::assertSame($composition->references(), $composition->references());
|
||
}
|
||
|
||
/** E13a: the composition hands its link targets to Placeholders; the result still passes ValueCleaner. */
|
||
public function testInternalLinksUseTheLinkTargets(): void
|
||
{
|
||
$composer = $this->composition()->forPost(7)->composer();
|
||
|
||
$column = Block::create('divi/column');
|
||
|
||
$page = $composer->compose([['type' => 'button', 'text' => 'Kontakt', 'url' => '{{link:839}}']], $column, 'nodes');
|
||
self::assertSame([], $page['errors']);
|
||
self::assertStringContainsString(
|
||
'post_link_url_page',
|
||
json_encode($page['blocks'][0]->attrs(), JSON_THROW_ON_ERROR)
|
||
);
|
||
|
||
$missing = $composer->compose([['type' => 'button', 'url' => '{{link:999}}']], $column, 'nodes');
|
||
self::assertStringContainsString('Eintrag 999', $missing['errors']['nodes[0].url']);
|
||
}
|
||
|
||
public function testWithoutAPostTheDefaultMapApplies(): void
|
||
{
|
||
$composition = $this->composition();
|
||
|
||
$result = $composition->attrWriter()->apply(
|
||
'divi/text',
|
||
[],
|
||
['content.innerContent' => ['desktop' => self::siteTitle()]],
|
||
'attrs'
|
||
);
|
||
|
||
self::assertStringContainsString('Unbekannter Name "site_title"', $result['errors']['attrs.content.innerContent']);
|
||
self::assertSame([], $this->registryCalls);
|
||
}
|
||
|
||
public function testForPostIsAFreshCompositionPerPost(): void
|
||
{
|
||
$base = $this->composition();
|
||
$seven = $base->forPost(7);
|
||
$eight = $base->forPost(8);
|
||
|
||
self::assertNotSame($base, $seven);
|
||
self::assertNotSame($seven->placeholders(), $eight->placeholders());
|
||
self::assertNotSame($base->placeholders(), $seven->placeholders());
|
||
self::assertNotSame($seven->references(), $eight->references());
|
||
self::assertNotSame($base->references(), $seven->references());
|
||
self::assertSame([7, 8], $this->registryCalls);
|
||
}
|
||
|
||
public function testRulesEditorAndWarnings(): void
|
||
{
|
||
$composition = $this->composition();
|
||
|
||
self::assertNull($composition->rules()->violation(Block::create('divi/column'), 'divi/button'));
|
||
self::assertNotNull($composition->rules()->violation(null, 'divi/button'));
|
||
self::assertSame([], $composition->warnings()->collect(NodeIndex::build(new Document()), []));
|
||
self::assertInstanceOf(TreeEditor::class, $composition->editor());
|
||
}
|
||
|
||
public function testStoredGuardSharesTheCleanerAndReferences(): void
|
||
{
|
||
$composition = DiviWriterFixture::composition(DiviWriterFixture::designReader())->forPost(7);
|
||
$code = Block::create('divi/code', ['builderVersion' => '5.2.1']);
|
||
|
||
self::assertInstanceOf(StoredNodeGuard::class, $composition->storedGuard());
|
||
self::assertSame(
|
||
[],
|
||
$composition->storedGuard()->problems(Block::create('divi/text', ['builderVersion' => '5.2.1']))
|
||
);
|
||
self::assertNotSame([], $composition->storedGuard()->problems($code), 'the editor sanitizer is used');
|
||
self::assertSame(
|
||
[],
|
||
DiviWriterFixture::composition(DiviWriterFixture::designReader(), true)->storedGuard()->problems($code)
|
||
);
|
||
}
|
||
|
||
public function testPresetStackAndGroupsComeFromTheWritersBookAndAreReadOnce(): void
|
||
{
|
||
$composer = $this->composition($this->presetSource())->forPost(7)->composer();
|
||
|
||
$result = $composer->compose([
|
||
['type' => 'button', 'text' => 'Eins', 'preset' => ['CTA primär', 'Standard'], 'group_presets' => ['font' => 'g-1']],
|
||
['type' => 'button', 'text' => 'Zwei', 'preset' => 'p-2'],
|
||
], Block::create('divi/column'), 'nodes');
|
||
|
||
self::assertSame([], $result['errors']);
|
||
self::assertSame(['p-2', 'p-1'], $result['blocks'][0]->attrs()['modulePreset']);
|
||
self::assertSame(['p-2'], $result['blocks'][1]->attrs()['modulePreset']);
|
||
self::assertSame(
|
||
['button.decoration.font.font' => ['groupName' => 'divi/font', 'presetIds' => ['g-1']]],
|
||
NodeSummary::groupPresets($result['blocks'][0])
|
||
);
|
||
}
|
||
|
||
public function testThePresetBookIsNotReadWithoutAPresetInTheInput(): void
|
||
{
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->shouldNotReceive('presets');
|
||
$writer->shouldNotReceive('groupsOf');
|
||
|
||
$result = $this->composition($writer)->forPost(7)->composer()->compose(
|
||
[['type' => 'button', 'text' => 'Los']],
|
||
Block::create('divi/column'),
|
||
'nodes'
|
||
);
|
||
|
||
self::assertSame([], $result['errors']);
|
||
}
|
||
|
||
public function testWithoutAPresetSourceNoPresetExists(): void
|
||
{
|
||
$result = $this->composition()->forPost(7)->composer()->compose(
|
||
[['type' => 'button', 'text' => 'Los', 'preset' => 'Standard']],
|
||
Block::create('divi/column'),
|
||
'nodes'
|
||
);
|
||
|
||
self::assertSame(
|
||
'unbekanntes Preset; vorhanden für divi/button: (keine)',
|
||
$result['errors']['nodes[0].preset']
|
||
);
|
||
}
|
||
|
||
public function testOnePresetAssignmentPerBoundComposition(): void
|
||
{
|
||
$base = $this->composition($this->presetSource(0));
|
||
$seven = $base->forPost(7);
|
||
|
||
self::assertSame($seven->presets(), $seven->presets());
|
||
self::assertNotSame($seven->presets(), $base->forPost(8)->presets());
|
||
self::assertNotSame($base->presets(), $seven->presets());
|
||
}
|
||
|
||
public function testWarningsKnowTheWritersPresets(): void
|
||
{
|
||
$composition = $this->composition($this->presetSource())->forPost(7);
|
||
$button = Block::create('divi/button', [
|
||
'modulePreset' => ['p-2'],
|
||
'button' => ['decoration' => ['font' => ['font' => ['desktop' => ['value' => ['color' => '#000000']]]]]],
|
||
]);
|
||
$document = new Document();
|
||
$document->append($button);
|
||
$index = NodeIndex::build($document);
|
||
|
||
$warnings = $composition->warnings()->collect($index, [$button]);
|
||
|
||
$covering = array_values(
|
||
array_filter($warnings, static fn (string $w): bool => str_contains($w, 'überdecken'))
|
||
);
|
||
self::assertCount(1, $covering);
|
||
self::assertStringContainsString('„CTA primär“', $covering[0]);
|
||
}
|
||
|
||
/**
|
||
* Ruling X3: inLoop() gives a fresh composition bound to a loop scope – placeholders and references of the
|
||
* entry's map plus the loop options of that kind; outside a loop and in another kind loop content fails.
|
||
*/
|
||
public function testInLoopChecksLoopContentAgainstItsScope(): void
|
||
{
|
||
$post = $this->composition()->forPost(7);
|
||
$inPostLoop = $post->inLoop(LoopScope::ofQueryType('post_types'));
|
||
$compose = static fn (DiviComposition $composition, string $content = '{{loop:post_title}}'): array => $composition
|
||
->composer()->compose(
|
||
[['type' => 'text', 'content' => $content]],
|
||
Block::create('divi/column'),
|
||
'nodes'
|
||
);
|
||
|
||
$inside = $compose($inPostLoop);
|
||
self::assertSame([], $inside['errors']);
|
||
self::assertSame(
|
||
VariableRef::serialize('content', 'loop_post_title', ['before' => '', 'after' => '']),
|
||
AttrMap::desktop($inside['blocks'][0]->attrs(), 'content.innerContent')
|
||
);
|
||
|
||
// Placeholders get the loop fields: the short form carries before/after; outside a loop it is refused.
|
||
$date = $compose($inPostLoop, '{{loop:post_date}}');
|
||
self::assertSame([], $date['errors']);
|
||
self::assertStringContainsString(
|
||
'loop_post_date',
|
||
(string) AttrMap::desktop($date['blocks'][0]->attrs(), 'content.innerContent')
|
||
);
|
||
self::assertStringContainsString('braucht eine Divi-Schleife', implode(' ', $compose($post, '{{loop:post_date}}')['errors']));
|
||
|
||
self::assertStringContainsString('braucht eine Divi-Schleife', implode(' ', $compose($post)['errors']));
|
||
self::assertStringContainsString(
|
||
'passt nicht zur Schleife dieses Elements (Typ: Terme)',
|
||
implode(' ', $compose($post->inLoop(LoopScope::ofQueryType('post_taxonomies')))['errors'])
|
||
);
|
||
|
||
self::assertNotSame($post->references(), $inPostLoop->references());
|
||
self::assertNotSame($post->placeholders(), $inPostLoop->placeholders());
|
||
self::assertSame($inPostLoop->references(), $inPostLoop->references(), 'one VariableRef per scope');
|
||
}
|
||
}
|