*/ private array $registryCalls = []; private function composition(?DesignSystemWriter $presets = null): DiviComposition { $modules = Mockery::mock(DiviModules::class); $modules->allows('catalog')->andReturn(DiviFixtures::catalog()); $modules->allows('builderVersion')->andReturn('5.2.1'); $design = Mockery::mock(DesignSystemReader::class); $design->allows('read')->andReturn(DiviFixtures::designSystem()); $registry = new DynamicContentMap(function (int $postId): array { $this->registryCalls[] = $postId; return DiviFixtures::dynamicContentRegistry(); }); return new DiviComposition( $modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $registry, ComposeFixture::links(), presetSource: $presets ); } /** The preset source of the live wiring: Divi's stored presets (raw format) and the option groups per module. */ private function presetSource(int $reads = 1): DesignSystemWriter { $writer = Mockery::mock(DesignSystemWriter::class); $writer->expects('presets')->times($reads)->andReturn([ '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'], ]]], ]); $writer->allows('groupsOf')->andReturnUsing( static fn (string $module): array => $module === 'divi/button' ? ['button.decoration.font.font' => 'divi/font'] : [] ); return $writer; } private static function siteTitle(): string { return VariableRef::serialize('content', 'site_title', ['before' => '', 'after' => '']); } public function testOneLiveMapIsSharedByTheFullAndTheShortForm(): void { $composition = $this->composition()->forPost(7); // Full form (AttrWriter). $full = $composition->attrWriter()->apply( 'divi/text', [], ['content.innerContent' => ['desktop' => self::siteTitle()]], 'attrs' ); self::assertSame([], $full['errors']); self::assertSame(self::siteTitle(), AttrMap::desktop($full['attrs'], 'content.innerContent')); // Short form through the Composer (ShortformWriter) – same map, same AttrWriter. $short = $composition->composer()->compose( [['type' => 'button', 'text' => self::siteTitle()]], Block::create('divi/column'), 'nodes' ); self::assertSame([], $short['errors']); self::assertStringContainsString('site_title', json_encode($short['blocks'][0]->attrs(), JSON_THROW_ON_ERROR)); self::assertSame([7], $this->registryCalls, 'the registry is read once per post'); self::assertSame($composition->attrWriter(), $composition->attrWriter()); self::assertSame($composition->placeholders(), $composition->placeholders()); self::assertSame($composition->references(), $composition->references()); } /** E13a: the composition hands its link targets to Placeholders; the result still passes ValueCleaner. */ public function testInternalLinksUseTheLinkTargets(): void { $composer = $this->composition()->forPost(7)->composer(); $column = Block::create('divi/column'); $page = $composer->compose([['type' => 'button', 'text' => 'Kontakt', 'url' => '{{link:839}}']], $column, 'nodes'); self::assertSame([], $page['errors']); self::assertStringContainsString( 'post_link_url_page', json_encode($page['blocks'][0]->attrs(), JSON_THROW_ON_ERROR) ); $missing = $composer->compose([['type' => 'button', 'url' => '{{link:999}}']], $column, 'nodes'); self::assertStringContainsString('Eintrag 999', $missing['errors']['nodes[0].url']); } public function testWithoutAPostTheDefaultMapApplies(): void { $composition = $this->composition(); $result = $composition->attrWriter()->apply( 'divi/text', [], ['content.innerContent' => ['desktop' => self::siteTitle()]], 'attrs' ); self::assertStringContainsString('Unbekannter Name "site_title"', $result['errors']['attrs.content.innerContent']); self::assertSame([], $this->registryCalls); } public function testForPostIsAFreshCompositionPerPost(): void { $base = $this->composition(); $seven = $base->forPost(7); $eight = $base->forPost(8); self::assertNotSame($base, $seven); self::assertNotSame($seven->placeholders(), $eight->placeholders()); self::assertNotSame($base->placeholders(), $seven->placeholders()); self::assertNotSame($seven->references(), $eight->references()); self::assertNotSame($base->references(), $seven->references()); self::assertSame([7, 8], $this->registryCalls); } public function testRulesEditorAndWarnings(): void { $composition = $this->composition(); self::assertNull($composition->rules()->violation(Block::create('divi/column'), 'divi/button')); self::assertNotNull($composition->rules()->violation(null, 'divi/button')); self::assertSame([], $composition->warnings()->collect(NodeIndex::build(new Document()), [])); self::assertInstanceOf(TreeEditor::class, $composition->editor()); } public function testStoredGuardSharesTheCleanerAndReferences(): void { $composition = DiviWriterFixture::composition(DiviWriterFixture::designReader())->forPost(7); $code = Block::create('divi/code', ['builderVersion' => '5.2.1']); self::assertInstanceOf(StoredNodeGuard::class, $composition->storedGuard()); self::assertSame( [], $composition->storedGuard()->problems(Block::create('divi/text', ['builderVersion' => '5.2.1'])) ); self::assertNotSame([], $composition->storedGuard()->problems($code), 'the editor sanitizer is used'); self::assertSame( [], DiviWriterFixture::composition(DiviWriterFixture::designReader(), true)->storedGuard()->problems($code) ); } public function testPresetStackAndGroupsComeFromTheWritersBookAndAreReadOnce(): void { $composer = $this->composition($this->presetSource())->forPost(7)->composer(); $result = $composer->compose([ ['type' => 'button', 'text' => 'Eins', 'preset' => ['CTA primär', 'Standard'], 'group_presets' => ['font' => 'g-1']], ['type' => 'button', 'text' => 'Zwei', 'preset' => 'p-2'], ], Block::create('divi/column'), 'nodes'); self::assertSame([], $result['errors']); self::assertSame(['p-2', 'p-1'], $result['blocks'][0]->attrs()['modulePreset']); self::assertSame(['p-2'], $result['blocks'][1]->attrs()['modulePreset']); self::assertSame( ['button.decoration.font.font' => ['groupName' => 'divi/font', 'presetIds' => ['g-1']]], NodeSummary::groupPresets($result['blocks'][0]) ); } public function testThePresetBookIsNotReadWithoutAPresetInTheInput(): void { $writer = Mockery::mock(DesignSystemWriter::class); $writer->shouldNotReceive('presets'); $writer->shouldNotReceive('groupsOf'); $result = $this->composition($writer)->forPost(7)->composer()->compose( [['type' => 'button', 'text' => 'Los']], Block::create('divi/column'), 'nodes' ); self::assertSame([], $result['errors']); } public function testWithoutAPresetSourceNoPresetExists(): void { $result = $this->composition()->forPost(7)->composer()->compose( [['type' => 'button', 'text' => 'Los', 'preset' => 'Standard']], Block::create('divi/column'), 'nodes' ); self::assertSame( 'unbekanntes Preset; vorhanden für divi/button: (keine)', $result['errors']['nodes[0].preset'] ); } public function testOnePresetAssignmentPerBoundComposition(): void { $base = $this->composition($this->presetSource(0)); $seven = $base->forPost(7); self::assertSame($seven->presets(), $seven->presets()); self::assertNotSame($seven->presets(), $base->forPost(8)->presets()); self::assertNotSame($base->presets(), $seven->presets()); } public function testWarningsKnowTheWritersPresets(): void { $composition = $this->composition($this->presetSource())->forPost(7); $button = Block::create('divi/button', [ 'modulePreset' => ['p-2'], 'button' => ['decoration' => ['font' => ['font' => ['desktop' => ['value' => ['color' => '#000000']]]]]], ]); $document = new Document(); $document->append($button); $index = NodeIndex::build($document); $warnings = $composition->warnings()->collect($index, [$button]); $covering = array_values( array_filter($warnings, static fn (string $w): bool => str_contains($w, 'überdecken')) ); self::assertCount(1, $covering); self::assertStringContainsString('„CTA primär“', $covering[0]); } /** * Ruling X3: inLoop() gives a fresh composition bound to a loop scope – placeholders and references of the * entry's map plus the loop options of that kind; outside a loop and in another kind loop content fails. */ public function testInLoopChecksLoopContentAgainstItsScope(): void { $post = $this->composition()->forPost(7); $inPostLoop = $post->inLoop(LoopScope::ofQueryType('post_types')); $compose = static fn (DiviComposition $composition, string $content = '{{loop:post_title}}'): array => $composition ->composer()->compose( [['type' => 'text', 'content' => $content]], Block::create('divi/column'), 'nodes' ); $inside = $compose($inPostLoop); self::assertSame([], $inside['errors']); self::assertSame( VariableRef::serialize('content', 'loop_post_title', ['before' => '', 'after' => '']), AttrMap::desktop($inside['blocks'][0]->attrs(), 'content.innerContent') ); // Placeholders get the loop fields: the short form carries before/after; outside a loop it is refused. $date = $compose($inPostLoop, '{{loop:post_date}}'); self::assertSame([], $date['errors']); self::assertStringContainsString( 'loop_post_date', (string) AttrMap::desktop($date['blocks'][0]->attrs(), 'content.innerContent') ); self::assertStringContainsString('braucht eine Divi-Schleife', implode(' ', $compose($post, '{{loop:post_date}}')['errors'])); self::assertStringContainsString('braucht eine Divi-Schleife', implode(' ', $compose($post)['errors'])); self::assertStringContainsString( 'passt nicht zur Schleife dieses Elements (Typ: Terme)', implode(' ', $compose($post->inLoop(LoopScope::ofQueryType('post_taxonomies')))['errors']) ); self::assertNotSame($post->references(), $inPostLoop->references()); self::assertNotSame($post->placeholders(), $inPostLoop->placeholders()); self::assertSame($inPostLoop->references(), $inPostLoop->references(), 'one VariableRef per scope'); } }