Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Compose/ComposerTest.php
T
janniclas.loosenandClaude Opus 5.5 cda8b025cc feat: explain the type collision on contact fields (trial)
A contact-field item whose type is no allowed child now gets a hint that
type is the node type, with the attrs path (fieldItem.advanced.type) and
the lit-divi-update set alternative, instead of "unbekanntes Modul
divi/email". The metadata fixture gains contact-form/contact-field
(regenerated from Divi 5.2.1, additions only).

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

556 lines
29 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\Support\ComposeFixture;
use LIT\MCP\Tests\Support\DiviFixtures;
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 S38: one change may not reach the same attribute path through set and attrs. */
public function testUpdateRefusesSetAndAttrsOnTheSamePath(): void
{
$block = Block::create('divi/button', ['button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Alt']]]]]);
$result = ComposeFixture::composer()->update($block, [
'set' => ['text' => 'Neu'],
'attrs' => ['button.innerContent' => ['desktop' => ['linkUrl' => '/kontakt']]],
], 'changes[0]');
self::assertStringContainsString('set.text', $result['errors']['changes[0].attrs.button.innerContent']);
self::assertSame([], $result['invalid_paths']);
$separate = ComposeFixture::composer()->update($block, [
'set' => ['text' => 'Neu'],
'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#ffffff']]],
], 'changes[0]');
self::assertSame([], $separate['errors'], 'different paths may be combined');
}
/** Ruling S40: a new node may not reach the same attribute path through short form and attrs. */
public function testComposeRefusesShortFormAndAttrsOnTheSamePath(): void
{
$result = ComposeFixture::composer()->compose([[
'type' => 'section',
'background_color' => '#ffffff',
'attrs' => ['module.decoration.background' => ['desktop' => ['color' => '#000000']]],
'modules' => [[
'type' => 'button',
'text' => 'Neu',
'attrs' => ['button.innerContent' => ['desktop' => ['linkUrl' => '/kontakt']]],
]],
]], null, 'nodes');
self::assertStringContainsString('background_color', $result['errors']['nodes[0].attrs.module.decoration.background']);
self::assertStringContainsString('text', $result['errors']['nodes[0].modules[0].modules[0][0].attrs.button.innerContent']);
self::assertSame([], $result['invalid_paths']);
$separate = ComposeFixture::composer()->compose([[
'type' => 'section',
'modules' => [
['type' => 'button', 'text' => 'Neu', 'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#ffffff']]]],
['type' => 'heading', 'text' => 'Titel', 'attrs' => ['title.decoration.font.font' => ['phone' => ['size' => '20px']]]],
],
]], null, 'nodes');
self::assertSame([], $separate['errors'], 'different paths, and the default heading level, may be combined');
}
/** Ruling S41: an admin label given in attrs is kept (no auto label over it); label + attrs on it is an error. */
public function testAdminLabelInAttrs(): void
{
$kept = ComposeFixture::composer()->compose([[
'type' => 'heading',
'text' => 'x',
'attrs' => ['module.meta.adminLabel' => ['desktop' => 'Mein Label', 'tablet' => 'Tablet']],
]], Block::create('divi/column'), 'nodes', 'Hero');
self::assertSame([], $kept['errors']);
self::assertSame(
['desktop' => ['value' => 'Mein Label'], 'tablet' => ['value' => 'Tablet']],
$kept['blocks'][0]->attrs()['module']['meta']['adminLabel']
);
$both = ComposeFixture::composer()->compose([[
'type' => 'heading',
'text' => 'x',
'label' => 'B',
'attrs' => ['module.meta.adminLabel' => ['desktop' => 'A', 'tablet' => 'T']],
]], Block::create('divi/column'), 'nodes', 'Hero');
self::assertStringContainsString('label', $both['errors']['nodes[0].attrs.module.meta.adminLabel']);
$update = ComposeFixture::composer()->update(Block::create('divi/heading'), [
'label' => 'B',
'attrs' => ['module.meta.adminLabel' => ['tablet' => 'T']],
], 'changes[0]');
self::assertStringContainsString('label', $update['errors']['changes[0].attrs.module.meta.adminLabel']);
}
/** Ruling S43: an unknown path wins over the overlap message on the same key. */
public function testUnknownPathWinsOverOverlap(): void
{
$result = ComposeFixture::composer()->compose([[
'type' => 'heading',
'text' => 'x',
'label' => 'B',
'attrs' => ['module.meta' => ['desktop' => 'A']],
]], Block::create('divi/column'), 'nodes');
self::assertSame(['module.meta'], $result['invalid_paths']);
self::assertStringContainsString('unbekannter Attributpfad', $result['errors']['nodes[0].attrs.module.meta']);
}
/** Ruling S41: the default heading level survives attrs on the same font value. */
public function testDefaultHeadingLevelIsMergedIntoAttrs(): void
{
$result = ComposeFixture::composer()->compose([[
'type' => 'heading',
'text' => 'x',
'attrs' => ['title.decoration.font.font' => ['desktop' => ['color' => '#111111']]],
]], Block::create('divi/column'), 'nodes');
self::assertSame([], $result['errors']);
self::assertSame(
['headingLevel' => 'h2', 'color' => '#111111'],
$result['blocks'][0]->attrs()['title']['decoration']['font']['font']['desktop']['value']
);
$explicit = ComposeFixture::composer()->compose([[
'type' => 'heading',
'text' => 'x',
'attrs' => ['title.decoration.font.font' => ['desktop' => ['headingLevel' => 'h3']]],
]], Block::create('divi/column'), 'nodes');
self::assertSame('h3', $explicit['blocks'][0]->attrs()['title']['decoration']['font']['font']['desktop']['value']['headingLevel']);
}
/** 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(
DiviFixtures::catalog(),
ComposeFixture::rules(),
ComposeFixture::shortform(),
ComposeFixture::attrWriter(),
ComposeFixture::cleaner(),
DiviFixtures::designSystem(),
'5.2.1"},"x":{"'
);
}
public function testAutoLabel(): void
{
$composer = ComposeFixture::composer();
$label = static fn (array $result): mixed => $result['blocks'][0]->attrs()['module']['meta']['adminLabel']['desktop']['value'] ?? null;
self::assertSame('Hero – Button', $label($composer->compose([['type' => 'button', 'text' => 'Los']], Block::create('divi/column'), 'n', 'Hero')));
self::assertSame('Button', $label($composer->compose([['type' => 'button', 'text' => 'Los']], Block::create('divi/column'), 'n', '')));
}
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());
}
/** Ledger T3: a nested row's auto label differs from its outer row's label. */
public function testNestedRowGetsItsOwnAutoLabel(): void
{
$label = static fn (Block $block): mixed => $block->attrs()['module']['meta']['adminLabel']['desktop']['value'] ?? null;
$result = ComposeFixture::composer()->compose([['type' => 'section', 'label' => 'Hero', 'rows' => [[
'columns' => '1_2,1_2',
'modules' => [[['type' => 'row', 'modules' => [['type' => 'text', 'content' => '<p>a</p>']]]], []],
]]]], null, 'nodes');
self::assertSame([], $result['errors']);
$outer = $result['blocks'][0]->children()[0];
$nested = $outer->children()[0]->children()[0];
self::assertSame('Hero – Zeile', $label($outer));
self::assertSame('Hero – Zeile (innen)', $label($nested));
self::assertSame('Hero – Text', $label($nested->children()[0]->children()[0]));
// Inserted straight into an existing column (lit-divi-insert parent = column): nested as well.
$inserted = ComposeFixture::composer()->compose([['type' => 'row']], Block::create('divi/column'), 'nodes', 'Hero');
self::assertSame('Hero – Zeile (innen)', $label($inserted['blocks'][0]));
$intoSection = ComposeFixture::composer()->compose([['type' => 'row']], Block::create('divi/section'), 'nodes', 'Hero');
self::assertSame('Hero – Zeile', $label($intoSection['blocks'][0]));
}
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']);
}
/**
* Trial feedback: `type` on a contact field is the node type, not the field type. A value that
* is no allowed child gets a dedicated hint instead of "unbekanntes Modul divi/email".
*/
public function testTypeOnAnItemWithATypeFieldExplainsTheCollision(): void
{
$result = ComposeFixture::composer()->compose([['type' => 'section', 'modules' => [
['type' => 'contact-form', 'items' => [
['title' => 'Name'],
['type' => 'email', 'title' => 'E-Mail'],
['type' => 'contact-field', 'title' => 'Nachricht'],
]],
]]], null, 'nodes');
$message = $result['errors']['nodes[0].modules[0].modules[0][0].items[1].type'] ?? '';
self::assertStringContainsString('`type` ist hier der Knotentyp', $message);
self::assertStringContainsString('fieldItem.advanced.type', $message);
self::assertStringContainsString('"email"', $message);
self::assertStringNotContainsString('unbekanntes Modul', $message);
self::assertCount(1, $result['errors'], 'the explicit contact-field type is fine');
}
}