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( '', 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' => '
Antwort
', 'open' => true]]], ['type' => 'text', 'content' => 'x
', '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', [], '', 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' => 'Hero', 'modules' => [['type' => 'heading', 'text' => 'x']]], ['type' => 'section', 'label' => '$variablea
']], []]]], [['type' => 'text', 'content' => 'b
']], ], ]]]], 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']); } }