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' => '$variable(' . 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)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;}'], ['type' => 'section', 'background_color' => 'red;}'], ['type' => 'section', 'background_color' => '{{post:title}}'], ['type' => 'section', 'background_color' => '$variable({"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' => [ 'p' => ['id' => 'p', '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']); } /** Button presets p-1/p-2 as in the sample design system; p-2 carries a font color, g-1 is a font group preset. */ private static function coveringBook(): PresetBook { return PresetBook::fromDivi([ 'module' => ['divi/button' => ['default' => 'p-1', 'items' => [ 'p-1' => ['id' => 'p-1', 'name' => 'Standard', 'moduleName' => 'divi/button'], 'p-2' => [ 'id' => 'p-2', 'name' => 'CTA primär', 'moduleName' => 'divi/button', 'attrs' => ['button' => ['decoration' => ['font' => ['font' => [ 'desktop' => ['value' => ['color' => '#ffffff']], ]]]]], ], ]]], 'group' => ['divi/font' => ['default' => '', 'items' => [ 'g-1' => ['id' => 'g-1', 'name' => 'Fließtext groß', 'groupName' => 'divi/font'], ]]], ]); } /** @return array The option groups the tests give every button. */ private static function buttonGroups(string $module): array { return $module === 'divi/button' ? ['button.decoration.font.font' => 'divi/font'] : []; } public function testPresetStackIsStoredInTheGivenOrder(): void { $composer = ComposeFixture::composer(); $result = $composer->compose( [['type' => 'button', 'text' => 'Los', 'preset' => ['CTA primär', 'p-1']]], Block::create('divi/column'), 'nodes' ); self::assertSame([], $result['errors']); self::assertSame(['p-2', 'p-1'], $result['blocks'][0]->attrs()['modulePreset']); $update = $composer->update(Block::create('divi/button'), ['preset' => ['Standard', 'CTA primär']], 'changes[0]'); self::assertSame([], $update['errors']); self::assertSame(['p-1', 'p-2'], $update['attrs']['modulePreset']); } public function testPresetStackErrorsKeepTheirInputPaths(): void { $composer = ComposeFixture::composer(); $button = Block::create('divi/button'); $unknown = $composer->update($button, ['preset' => ['Standard', 'Gibt es nicht']], 'changes[0]'); self::assertSame( 'unbekanntes Preset; vorhanden für divi/button: Standard, CTA primär', $unknown['errors']['changes[0].preset.1'] ); self::assertArrayNotHasKey('modulePreset', $unknown['attrs']); foreach ([[], ['Standard', 'p-1']] as $invalid) { $result = $composer->update($button, ['preset' => $invalid], 'changes[0]'); self::assertNotSame([], preg_grep('/^changes\[0\]\.preset/', array_keys($result['errors']))); self::assertArrayNotHasKey('modulePreset', $result['attrs']); } } public function testGroupPresetsAreAssignedByShortNameAndRemovedWithNull(): void { $composer = ComposeFixture::composer(false, null, null, self::buttonGroups(...)); $groups = ['button.decoration.font.font' => ['groupName' => 'divi/font', 'presetIds' => ['g-1']]]; $update = $composer->update(Block::create('divi/button'), ['group_presets' => ['font' => 'Fließtext groß']], 'changes[0]'); self::assertSame([], $update['errors']); self::assertSame($groups, NodeSummary::groupPresets(Block::create('divi/button', $update['attrs']))); $created = $composer->compose( [['type' => 'button', 'text' => 'Los', 'group_presets' => ['divi/font' => 'g-1']]], Block::create('divi/column'), 'nodes' ); self::assertSame([], $created['errors']); self::assertSame($groups, NodeSummary::groupPresets($created['blocks'][0])); $removed = $composer->update(Block::create('divi/button', $update['attrs']), ['group_presets' => ['font' => null]], 'changes[0]'); self::assertSame([], $removed['errors']); self::assertSame([], NodeSummary::groupPresets(Block::create('divi/button', $removed['attrs']))); self::assertArrayNotHasKey('groupPreset', $removed['attrs']); $unknown = $composer->update(Block::create('divi/button'), ['group_presets' => ['farbe' => 'x']], 'changes[0]'); self::assertArrayHasKey('changes[0].group_presets.farbe', $unknown['errors']); } /** Ruling R7: clear_overrides runs after set/attrs of the same node and removes only leaves a preset also sets. */ public function testClearOverridesRunsAfterSetAndAttrsOnANewNode(): void { $composer = ComposeFixture::composer(false, null, self::coveringBook()); $font = ['desktop' => ['color' => '#abcdef', 'family' => 'Open Sans']]; $kept = $composer->compose( [['type' => 'button', 'text' => 'Los', 'preset' => 'CTA primär', 'attrs' => ['button.decoration.font.font' => $font]]], Block::create('divi/column'), 'nodes' ); self::assertSame([], $kept['errors']); self::assertSame( ['color' => '#abcdef', 'family' => 'Open Sans'], AttrMap::desktop($kept['blocks'][0]->attrs(), 'button.decoration.font.font') ); $cleared = $composer->compose( [[ 'type' => 'button', 'text' => 'Los', 'preset' => 'CTA primär', 'clear_overrides' => true, 'attrs' => ['button.decoration.font.font' => $font], ]], Block::create('divi/column'), 'nodes' ); self::assertSame([], $cleared['errors']); $attrs = $cleared['blocks'][0]->attrs(); self::assertSame(['p-2'], $attrs['modulePreset']); self::assertSame(['family' => 'Open Sans'], AttrMap::desktop($attrs, 'button.decoration.font.font')); self::assertSame('Los', AttrMap::desktop($attrs, 'button.innerContent')['text']); } public function testClearOverridesOnAnUpdateKeepsEverythingNoPresetCovers(): void { $composer = ComposeFixture::composer(false, null, self::coveringBook()); $attrs = ['modulePreset' => ['p-2'], 'button' => ['decoration' => ['font' => ['font' => [ 'desktop' => ['value' => ['color' => '#000000']], ]]]]]; $result = $composer->update(Block::create('divi/button', $attrs), ['clear_overrides' => true], 'changes[0]'); self::assertSame([], $result['errors']); self::assertSame(['modulePreset' => ['p-2']], $result['attrs']); $plain = Block::create('divi/button', ['button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Alt']]]]]); $unchanged = $composer->update($plain, ['clear_overrides' => true], 'changes[0]'); self::assertSame([], $unchanged['errors']); self::assertSame($plain->attrs(), $unchanged['attrs']); $off = $composer->update(Block::create('divi/button', $attrs), ['clear_overrides' => false], 'changes[0]'); self::assertSame($attrs, $off['attrs']); } public function testClearOverridesMustBeABoolean(): void { $composer = ComposeFixture::composer(); $update = $composer->update(Block::create('divi/button'), ['clear_overrides' => 'ja'], 'changes[0]'); self::assertSame('true oder false erwartet', $update['errors']['changes[0].clear_overrides']); $compose = $composer->compose( [['type' => 'section', 'clear_overrides' => null, 'modules' => []]], null, 'nodes' ); self::assertSame('true oder false erwartet', $compose['errors']['nodes[0].clear_overrides']); } public function testBuilderVersionMustBeAVersion(): void { $this->expectException(InvalidArgumentException::class); new Composer( DiviFixtures::catalog(), ComposeFixture::rules(), ComposeFixture::shortform(), ComposeFixture::attrWriter(), ComposeFixture::cleaner(), ComposeFixture::presetAssignment(), '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', [], '', 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' => '

a

']], []]]], [['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()); } /** 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' => '

a

']]]], []], ]]]], 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'); } }