Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Compose/ComposerTest.php
T
janniclas.loosenandClaude Opus 5.5 4b72c2bb42 feat: Divi composer – short/full form to blocks with structure rules, presets and labels
Labels, background_color and preset ids go through the choke point (S13);
ambiguous preset names fail with their ids (S13a); fullwidth + rows is an
error. Carry-ins: control characters stripped before the marker check (S32),
kind override may only tighten, reference alt texts dropped, color check via
VariableRef::typeOf (S33).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 14:15:33 +02:00

367 lines
19 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\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Compose\Composer;
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
use PHPUnit\Framework\TestCase;
final class ComposerTest extends TestCase
{
public function testBuildsASectionFromTheShortForm(): void
{
$result = ComposeFixture::composer()->compose([[
'type' => 'section',
'label' => 'Hero',
'background_color' => '{{color:Nachtblau}}',
'rows' => [[
'columns' => '1_3,2_3',
'modules' => [
[['type' => 'image', 'image_id' => 33]],
[
['type' => 'heading', 'text' => 'Willkommen'],
['type' => 'button', 'text' => 'Termin buchen', 'url' => '/kontakt', 'preset' => 'CTA primär'],
],
],
]],
]], null, 'nodes');
self::assertSame([], $result['errors']);
self::assertStringStartsWith(
'<!-- wp:divi/section {"builderVersion":"5.2.1","module":{"decoration":{"background":{"desktop":{"value":'
. '{"color":"$variable({\u0022type\u0022:\u0022color\u0022,\u0022value\u0022:{\u0022name\u0022:'
. '\u0022gcid-a1b2\u0022,\u0022settings\u0022:{}}})$"}}}},"meta":{"adminLabel":{"desktop":{"value":'
. '"Hero"}}}}} --><!-- wp:divi/row {"builderVersion":"5.2.1","module":{"meta":{"adminLabel":'
. '{"desktop":{"value":"Hero – Zeile"}}}}} --><!-- wp:divi/column {"builderVersion":"5.2.1","module":'
. '{"decoration":{"sizing":{"desktop":{"value":{"flexType":"8_24"}}}}}} --><!-- wp:divi/image '
. '{"builderVersion":"5.2.1","image":{"innerContent":{"desktop":{"value":{"src":'
. '"https://kunde.test/wp-content/uploads/team.jpg","id":"33","alt":"Unser Team"}}}},"module":{"meta":'
. '{"adminLabel":{"desktop":{"value":"Hero – Image"}}}}} --><!-- /wp:divi/image -->',
Serializer::block($result['blocks'][0])
);
$section = $result['blocks'][0];
[$heading, $button] = $section->children()[0]->children()[1]->children();
self::assertSame('16_24', NodeSummary::columnWidth($section->children()[0]->children()[1]));
self::assertSame('h2', $heading->attrs()['title']['decoration']['font']['font']['desktop']['value']['headingLevel']);
self::assertSame('Hero – Heading', NodeSummary::label($heading));
self::assertSame(['p-2'], $button->attrs()['modulePreset']);
// What the composer writes, the parser reads back unchanged.
$markup = Serializer::block($section);
self::assertSame($markup, Serializer::document((new Parser())->parse($markup)));
self::assertFalse((new Parser())->parse($markup)->children()[0]->isOpaque());
}
public function testShortcutsItemsFullwidthAndFullForm(): void
{
$result = ComposeFixture::composer()->compose([
['type' => 'section', 'modules' => [
['type' => 'accordion', 'items' => [['title' => 'Frage', 'content' => '<p>Antwort</p>', 'open' => true]]],
['type' => 'text', 'content' => '<p>x</p>', 'label' => 'Einleitung',
'attrs' => ['module.decoration.spacing' => ['desktop' => ['padding' => ['top' => '20px']]]]],
]],
['type' => 'section', 'section_type' => 'fullwidth', 'modules' => [
['type' => 'fullwidth-header', 'attrs' => ['title.innerContent' => ['desktop' => 'Großer Titel']]],
]],
], null, 'nodes');
self::assertSame([], $result['errors']);
[$regular, $fullwidth] = $result['blocks'];
$column = $regular->children()[0]->children()[0];
self::assertSame('24_24', NodeSummary::columnWidth($column));
[$accordion, $text] = $column->children();
self::assertSame('divi/accordion-item', $accordion->children()[0]->name());
self::assertSame('on', $accordion->children()[0]->attrs()['module']['advanced']['open']['desktop']['value']);
self::assertSame('Einleitung', NodeSummary::label($text));
self::assertSame(['top' => '20px'], $text->attrs()['module']['decoration']['spacing']['desktop']['value']['padding']);
self::assertSame('fullwidth', $fullwidth->attrs()['module']['advanced']['type']['desktop']['value']);
self::assertSame('divi/fullwidth-header', $fullwidth->children()[0]->name());
}
public function testCollectsEveryErrorWithItsInputPath(): void
{
$result = ComposeFixture::composer()->compose([
['type' => 'button', 'text' => 'x'],
['type' => 'section', 'rows' => [
['columns' => '1_2,1_3', 'modules' => []],
['columns' => '1_2,1_2', 'modules' => [[['type' => 'slider-pro']], [['type' => 'accordion-item']]]],
]],
['type' => 'section', 'preset' => 'Gibt es nicht', 'foo' => 1],
['type' => 'section', 'modules' => [['type' => 'heading', 'attrs' => ['title.fnt' => ['desktop' => 'x']]]]],
], null, 'nodes');
self::assertSame('auf oberster Ebene sind nur Abschnitte (section) erlaubt', $result['errors']['nodes[0].type']);
self::assertStringContainsString('volle Zeile', $result['errors']['nodes[1].rows[0].columns']);
self::assertStringContainsString('unbekanntes Modul "divi/slider-pro"', $result['errors']['nodes[1].rows[1].modules[0][0].type']);
self::assertStringContainsString('passt nicht in eine Spalte', $result['errors']['nodes[1].rows[1].modules[1][0].type']);
self::assertStringContainsString('unbekanntes Preset', $result['errors']['nodes[2].preset']);
self::assertStringContainsString('unbekanntes Feld', $result['errors']['nodes[2].foo']);
self::assertSame(['title.fnt'], $result['invalid_paths']);
self::assertArrayHasKey('nodes[3].modules[0].modules[0][0].attrs.title.fnt', $result['errors']);
}
public function testColumnWidths(): void
{
$composer = ComposeFixture::composer();
$widths = static function (string $columns) use ($composer): array {
$result = $composer->compose([['type' => 'section', 'rows' => [['columns' => $columns]]]], null, 'n');
return [
array_map([NodeSummary::class, 'columnWidth'], $result['blocks'][0]->children()[0]->children()),
$result['errors'],
];
};
self::assertSame([['12_24', '12_24'], []], $widths('1_2,1_2'));
self::assertSame([['6_24', '18_24'], []], $widths('1_4, 3_4'));
self::assertSame([['2_5', '3_5'], []], $widths('2_5,3_5'));
self::assertSame(['24_24'], $widths('1_7,5_7')[0]);
self::assertArrayHasKey('n[0].rows[0].columns', $widths('1_7,5_7')[1]);
self::assertArrayHasKey('n[0].rows[0].columns', $widths('abc')[1]);
}
public function testUpdateChangesShortFormPresetAndLabel(): void
{
$block = Block::create('divi/button', ['button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Alt']]]]]);
$result = ComposeFixture::composer()->update($block, [
'set' => ['text' => 'Neu', 'url' => '/neu'],
'preset' => 'Standard',
'label' => 'Hero – CTA',
], 'changes[0]');
self::assertSame([], $result['errors']);
self::assertSame(['text' => 'Neu', 'linkUrl' => '/neu'], $result['attrs']['button']['innerContent']['desktop']['value']);
self::assertSame(['p-1'], $result['attrs']['modulePreset']);
self::assertSame('Hero – CTA', $result['attrs']['module']['meta']['adminLabel']['desktop']['value']);
// update() only computes attributes; the block itself stays as it was.
self::assertArrayNotHasKey('modulePreset', $block->attrs());
$cleared = ComposeFixture::composer()->update(
Block::create('divi/button', $result['attrs']),
['preset' => null, 'label' => null],
'changes[0]'
);
self::assertSame([], $cleared['errors']);
self::assertArrayNotHasKey('modulePreset', $cleared['attrs']);
self::assertArrayNotHasKey('module', $cleared['attrs']);
}
public function testUpdateReportsBadInput(): void
{
$block = Block::create('divi/button', ['builderVersion' => '5.2.1']);
$result = ComposeFixture::composer()->update($block, [
'id' => 's0.r0.c0.m0',
'set' => ['nope'],
'label' => 42,
'colour' => 'x',
], 'changes[1]');
self::assertArrayHasKey('changes[1].set', $result['errors']);
self::assertArrayHasKey('changes[1].label', $result['errors']);
self::assertStringContainsString('unbekanntes Feld', $result['errors']['changes[1].colour']);
self::assertArrayNotHasKey('changes[1].id', $result['errors']);
self::assertSame(['builderVersion' => '5.2.1'], $result['attrs']);
$opaque = Block::parsed('divi/button', [], '<!-- wp:divi/button -->', false, true);
$readOnly = ComposeFixture::composer()->update($opaque, ['label' => 'x'], 'changes[2]');
self::assertStringContainsString('nur lesbar', $readOnly['errors']['changes[2].id']);
}
/** Ruling S13: labels go through the choke point; a label that fails is an error, never stored raw. */
public function testLabelsAreCleanedOrReported(): void
{
$reference = '$variable({"type":"content","value":{"name":"post_title","settings":{}}})$';
$result = ComposeFixture::composer()->compose([
['type' => 'section', 'label' => '<b>Hero</b>', 'modules' => [['type' => 'heading', 'text' => 'x']]],
['type' => 'section', 'label' => '$variable<x>(' . substr($reference, strlen('$variable(')),
'modules' => [['type' => 'heading', 'text' => 'x']]],
['type' => 'section', 'label' => '$variable' . chr(0) . substr($reference, strlen('$variable'))],
['type' => 'section', 'label' => $reference],
['type' => 'section', 'label' => ['Hero']],
['type' => 'section', 'modules' => [['type' => 'heading', 'label' => "Titel\x01\x7f"]]],
], null, 'nodes');
[$clean, $split, $nul, $whole, $list, $control] = $result['blocks'];
self::assertSame('Hero', NodeSummary::label($clean));
self::assertSame('Hero – Heading', NodeSummary::label($clean->children()[0]->children()[0]->children()[0]));
foreach ([1, 2, 3, 4] as $position) {
self::assertArrayHasKey(sprintf('nodes[%d].label', $position), $result['errors']);
}
foreach ([$split, $nul, $whole, $list] as $section) {
self::assertSame('', NodeSummary::label($section));
}
// No raw label reaches the children's auto labels either.
self::assertSame('Heading', NodeSummary::label($split->children()[0]->children()[0]->children()[0]));
self::assertStringNotContainsString('$variable', Serializer::block($split));
// Ruling S32: control characters are stripped from every final string.
self::assertSame('Titel', NodeSummary::label($control->children()[0]->children()[0]->children()[0]));
}
/** Ruling S13: background_color takes the short-form color check, not text(). */
public function testBackgroundColorIsAColor(): void
{
$result = ComposeFixture::composer()->compose([
['type' => 'section', 'background_color' => '#1a1a1a'],
['type' => 'section', 'background_color' => 'red;}</style><script>alert(1)</script>'],
['type' => 'section', 'background_color' => 'red;}'],
['type' => 'section', 'background_color' => '{{post:title}}'],
['type' => 'section', 'background_color' => '$variable<x>({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'],
['type' => 'section', 'background_color' => ['#fff']],
], null, 'nodes');
self::assertSame('#1a1a1a', $result['blocks'][0]->attrs()['module']['decoration']['background']['desktop']['value']['color']);
foreach ([1, 2, 3, 4, 5] as $position) {
self::assertStringContainsString('Farbe erwartet', $result['errors'][sprintf('nodes[%d].background_color', $position)]);
self::assertArrayNotHasKey('module', $result['blocks'][$position]->attrs());
}
}
/** Ruling S13: a fullwidth section holds fullwidth modules; rows there are an error, not ignored. */
public function testFullwidthSectionWithRowsIsAnError(): void
{
$result = ComposeFixture::composer()->compose([
['type' => 'section', 'section_type' => 'fullwidth', 'rows' => [['columns' => '1_2,1_2']]],
['type' => 'section', 'rows' => [], 'modules' => []],
['type' => 'section', 'section_type' => 'specialty'],
['type' => 'section', 'section_type' => 'fullwidth', 'modules' => [['type' => 'heading', 'text' => 'x']]],
], null, 'nodes');
self::assertStringContainsString('Vollbreite', $result['errors']['nodes[0].rows']);
self::assertSame([], $result['blocks'][0]->children());
self::assertStringContainsString('entweder rows oder modules', $result['errors']['nodes[1]']);
self::assertStringContainsString('specialty', $result['errors']['nodes[2].section_type']);
self::assertStringContainsString('fullwidth-Module', $result['errors']['nodes[3].modules[0].type']);
}
/** Ruling S13a: an ambiguous design-system name is an error listing the ids; ids work directly. */
public function testAmbiguousDesignNamesFailWithTheIds(): void
{
$design = DesignSystem::fromDivi(
[
'gcid-b1' => ['color' => '#00f', 'label' => 'Blau', 'status' => 'active'],
'gcid-b2' => ['color' => '#00a', 'label' => 'blau', 'status' => 'active'],
],
[],
['module' => ['divi/button' => ['default' => 'p-1', 'items' => [
'p-1' => ['id' => 'p-1', 'name' => 'Standard', 'moduleName' => 'divi/button'],
'p-3' => ['id' => 'p-3', 'name' => 'standard', 'moduleName' => 'divi/button'],
]]]],
[]
);
$composer = ComposeFixture::composer(false, $design);
$result = $composer->compose([
['type' => 'section', 'background_color' => '{{color:Blau}}', 'modules' => [
['type' => 'button', 'preset' => 'Standard'],
['type' => 'button', 'preset' => 'p-3'],
]],
], null, 'nodes');
self::assertStringContainsString('gcid-b1, gcid-b2', $result['errors']['nodes[0].background_color']);
self::assertStringContainsString('mehrdeutig', $result['errors']['nodes[0].modules[0].modules[0][0].preset']);
self::assertStringContainsString('p-1, p-3', $result['errors']['nodes[0].modules[0].modules[0][0].preset']);
self::assertArrayNotHasKey('nodes[0].modules[0].modules[0][1].preset', $result['errors']);
[$ambiguous, $byId] = $result['blocks'][0]->children()[0]->children()[0]->children();
self::assertArrayNotHasKey('modulePreset', $ambiguous->attrs());
self::assertSame(['p-3'], $byId->attrs()['modulePreset']);
$update = $composer->update(Block::create('divi/button'), ['preset' => 'STANDARD'], 'changes[0]');
self::assertStringContainsString('p-1, p-3', $update['errors']['changes[0].preset']);
}
/** Preset ids come from the design system but still pass the choke point unchanged. */
public function testPresetIdsPassTheChokePoint(): void
{
$design = DesignSystem::fromDivi([], [], ['module' => ['divi/button' => ['default' => '', 'items' => [
'<b>p</b>' => ['id' => '<b>p</b>', 'name' => 'Seltsam', 'moduleName' => 'divi/button'],
]]]], []);
$result = ComposeFixture::composer(false, $design)->update(
Block::create('divi/button'),
['preset' => 'Seltsam'],
'changes[0]'
);
self::assertArrayHasKey('changes[0].preset', $result['errors']);
self::assertArrayNotHasKey('modulePreset', $result['attrs']);
}
public function testBuilderVersionMustBeAVersion(): void
{
$this->expectException(InvalidArgumentException::class);
new Composer(
ModuleCatalogTest::sampleCatalog(),
ComposeFixture::rules(),
ComposeFixture::shortform(),
ComposeFixture::attrWriter(),
DesignSystemTest::sample(),
'5.2.1"},"x":{"'
);
}
public function testAutoLabel(): void
{
$composer = ComposeFixture::composer();
self::assertSame('Hero – Button', $composer->autoLabel('divi/button', 'Hero'));
self::assertSame('Button', $composer->autoLabel('divi/button', ''));
}
public function testComposesIntoAnExistingParent(): void
{
$composer = ComposeFixture::composer();
$column = Block::create('divi/column');
$accordion = Block::create('divi/accordion');
$modules = $composer->compose([['type' => 'button', 'text' => 'Los']], $column, 'nodes', 'Hero');
$items = $composer->compose([['title' => 'Frage']], $accordion, 'nodes');
self::assertSame([], $modules['errors']);
self::assertSame('Hero – Button', NodeSummary::label($modules['blocks'][0]));
self::assertSame([], $items['errors']);
self::assertSame('divi/accordion-item', $items['blocks'][0]->name());
// compose() builds blocks; inserting them is the caller's job.
self::assertSame([], $column->children());
$opaque = Block::parsed('divi/column', [], '<!-- wp:divi/column -->', false, true);
$readOnly = $composer->compose([['type' => 'button']], $opaque, 'nodes');
self::assertStringContainsString('nur lesbar', $readOnly['errors']['nodes[0].type']);
self::assertSame([], $readOnly['blocks']);
}
public function testRejectsNonObjectsAndMissingTypes(): void
{
$result = ComposeFixture::composer()->compose(['section', [], ['label' => 'x']], null, 'nodes');
self::assertArrayHasKey('nodes[0]', $result['errors']);
self::assertArrayHasKey('nodes[1]', $result['errors']);
self::assertSame('type fehlt', $result['errors']['nodes[2].type']);
self::assertSame([], $result['blocks']);
}
public function testTooManyModuleListsForTheColumns(): void
{
$result = ComposeFixture::composer()->compose([['type' => 'section', 'rows' => [[
'columns' => '1_2,1_2',
'modules' => [[], [], [['type' => 'text']]],
]]]], null, 'nodes');
self::assertStringContainsString('2 Spalten, aber 3 Modul-Listen', $result['errors']['nodes[0].rows[0].modules']);
}
}