Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Compose/ComposerTest.php
T
janniclas.loosenandClaude Opus 5.5 8688a02402 fix: Task 3 review round 1 - strip control chars before the sanitizer (S32a), S34 minors
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
2026-09-26 14:26:18 +02:00

394 lines
20 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]);
// 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']);
}
}