Control characters now go on the raw value in AttrWriter::cleanNode and
VariableRef::setting; after the sanitizer only rejection remains ('<' + VT/FF
no longer turns into a tag). VALUE_KEY anchored with D, exact column-width
fractions, nested rows in columns, specialty columns refused, fullwidth by
catalog category, preset ambiguity in DesignSystem::modulePresetId().
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
394 lines
20 KiB
PHP
394 lines
20 KiB
PHP
<?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]);
|
||
// Ruling S34: exact fractions – 1/11 + 11/12 is 133/132, not "about one".
|
||
self::assertArrayHasKey('n[0].rows[0].columns', $widths('1_11,11_12')[1]);
|
||
self::assertSame([['1_5', '1_5', '1_5', '1_5', '1_5'], []], $widths('1_5,1_5,1_5,1_5,1_5'));
|
||
self::assertSame([['8_24', '8_24', '8_24'], []], $widths('1_3,1_3,1_3'));
|
||
}
|
||
|
||
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"]]],
|
||
['type' => 'section', 'label' => '<' . chr(11) . 'img src=x onerror=alert(1)>Hero'],
|
||
['type' => 'section', 'label' => '<' . chr(12) . 'script>alert(1)</script>Hero'],
|
||
], null, 'nodes');
|
||
|
||
[$clean, $split, $nul, $whole, $list, $control, $vt, $ff] = $result['blocks'];
|
||
// Ruling S32a: controls go before the sanitizer, so no tag is built after it.
|
||
self::assertSame('Hero', NodeSummary::label($vt));
|
||
self::assertSame('Hero', NodeSummary::label($ff));
|
||
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']);
|
||
}
|
||
|
||
/** Ruling S34: Divi 5 rows are nestable – a row may go into a column. */
|
||
public function testNestedRowInAColumn(): void
|
||
{
|
||
$result = ComposeFixture::composer()->compose([['type' => 'section', 'label' => 'Hero', 'rows' => [[
|
||
'columns' => '1_2,1_2',
|
||
'modules' => [
|
||
[['type' => 'row', 'columns' => '1_2,1_2', 'modules' => [[['type' => 'text', 'content' => '<p>a</p>']], []]]],
|
||
[['type' => 'text', 'content' => '<p>b</p>']],
|
||
],
|
||
]]]], null, 'nodes');
|
||
|
||
self::assertSame([], $result['errors']);
|
||
$nested = $result['blocks'][0]->children()[0]->children()[0]->children()[0];
|
||
self::assertSame('divi/row', $nested->name());
|
||
self::assertSame(['12_24', '12_24'], array_map([NodeSummary::class, 'columnWidth'], $nested->children()));
|
||
self::assertSame('divi/text', $nested->children()[0]->children()[0]->name());
|
||
}
|
||
|
||
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']);
|
||
}
|
||
}
|