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]); } 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 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"]]], ], null, 'nodes'); [$clean, $split, $nul, $whole, $list, $control] = $result['blocks']; 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']); } 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', [], '', false, true); $readOnly = $composer->compose([['type' => 'button']], $opaque, 'nodes'); self::assertStringContainsString('nur lesbar', $readOnly['errors']['nodes[0].type']); self::assertSame([], $readOnly['blocks']); } 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']); } }