diff --git a/src/Integrations/Divi/Compose/Warnings.php b/src/Integrations/Divi/Compose/Warnings.php index 6953489..63051e8 100644 --- a/src/Integrations/Divi/Compose/Warnings.php +++ b/src/Integrations/Divi/Compose/Warnings.php @@ -9,6 +9,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; use LIT\MCP\Integrations\Divi\Design\DesignSystem; +use LIT\MCP\Integrations\Divi\Design\PresetAssignment; use LIT\MCP\Integrations\Divi\Schema\ShortformMap; defined('ABSPATH') || exit; @@ -16,8 +17,9 @@ defined('ABSPATH') || exit; /** * Quality warnings of a Divi write (spec §5.5) – only about the nodes this call created or * changed (and their children): fixed colors/fonts where a global one fits, images without - * alt text, empty modules, heading jumps, the code module. Similar existing sections - * (lit/library-find-similar) follow with the Library in Etappe 3. Pure PHP. + * alt text, empty modules, heading jumps, the code module, local values that cover an assigned + * preset (ruling R5). Similar existing sections (lit/library-find-similar) follow with the Library + * in Etappe 3. Pure PHP. */ final class Warnings { @@ -33,7 +35,7 @@ final class Warnings 'divi/slide' => ['image.innerContent', 'src', 'alt'], ]; - public function __construct(private DesignSystem $design) + public function __construct(private DesignSystem $design, private ?PresetAssignment $presets = null) { } @@ -58,7 +60,8 @@ final class Warnings ...$this->code($block, $id), ...$this->image($block, $id), ...$this->empty($block, $id), - ...$this->fixedValues($block, $id) + ...$this->fixedValues($block, $id), + ...$this->coveredPresets($block, $id) ); } @@ -161,6 +164,27 @@ final class Warnings return $warnings; } + /** + * Ruling R5: local values that cover an explicitly assigned preset of the block. Only Divi blocks that name a + * preset cost work (opaque blocks – global instances – are never looked into); the preset book is read when the + * first such block comes up, never for a page without presets. + * + * @return list + */ + private function coveredPresets(Block $block, string $id): array + { + if ($this->presets === null || ! $block->isDivi() || $block->isOpaque() || ! self::namesPresets($block)) { + return []; + } + + return $this->presets->overrides()->describe($id, $block->attrs(), $this->presets->names()); + } + + private static function namesPresets(Block $block): bool + { + return NodeSummary::presetIds($block) !== [] || NodeSummary::groupPresets($block) !== []; + } + /** * Heading jumps (h2 → h4) in page order where the later heading belongs to a touched block. * diff --git a/src/Integrations/Divi/Pages/DiviComposition.php b/src/Integrations/Divi/Pages/DiviComposition.php index 93b91dd..9a2a31d 100644 --- a/src/Integrations/Divi/Pages/DiviComposition.php +++ b/src/Integrations/Divi/Pages/DiviComposition.php @@ -160,6 +160,6 @@ class DiviComposition public function warnings(): Warnings { - return new Warnings($this->design->read()); + return new Warnings($this->design->read(), $this->presets()); } } diff --git a/tests/Support/ComposeFixture.php b/tests/Support/ComposeFixture.php index 7e1ae3a..d401586 100644 --- a/tests/Support/ComposeFixture.php +++ b/tests/Support/ComposeFixture.php @@ -247,21 +247,24 @@ final class ComposeFixture /** * PresetAssignment as the live wiring builds it: the book is $book (default: bookOf($design), else of the sample - * design system), the id check is the cleaner's keepsId(). + * design system), the id check is the cleaner's keepsId(). $load replaces $book with a loader the test can + * count. * * @param (Closure(string): array)|null $groupsOf + * @param (Closure(): PresetBook)|null $load */ public static function presetAssignment( ?PresetBook $book = null, ?Closure $groupsOf = null, ?DesignSystem $design = null, - ?ValueCleaner $cleaner = null + ?ValueCleaner $cleaner = null, + ?Closure $load = null ): PresetAssignment { $book ??= self::bookOf($design ?? DiviFixtures::designSystem()); $cleaner ??= self::cleaner(); return new PresetAssignment( - static fn (): PresetBook => $book, + $load ?? static fn (): PresetBook => $book, $groupsOf ?? static fn (string $module): array => [], $cleaner->keepsId(...) ); diff --git a/tests/Unit/Integrations/Divi/Compose/WarningsTest.php b/tests/Unit/Integrations/Divi/Compose/WarningsTest.php index f73ebad..5989476 100644 --- a/tests/Unit/Integrations/Divi/Compose/WarningsTest.php +++ b/tests/Unit/Integrations/Divi/Compose/WarningsTest.php @@ -4,9 +4,12 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; +use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Document; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\Compose\Warnings; +use LIT\MCP\Integrations\Divi\Design\PresetAssignment; +use LIT\MCP\Integrations\Divi\Design\PresetBook; use LIT\MCP\Tests\Support\ComposeFixture; use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; @@ -53,4 +56,144 @@ final class WarningsTest extends TestCase self::assertSame([], (new Warnings(DiviFixtures::designSystem()))->collect(NodeIndex::build($document), [])); } + + /** @return array Button attrs with a preset and a local font color. */ + private static function button(string $color, string $preset = 'p-2'): array + { + return [ + 'modulePreset' => [$preset], + 'button' => ['decoration' => ['font' => ['font' => ['desktop' => ['value' => ['color' => $color]]]]]], + ]; + } + + /** Preset p-2 "CTA primär" sets the font color #ffffff; g-1 is a group preset without values. */ + private static function book(): PresetBook + { + return PresetBook::fromDivi([ + 'module' => ['divi/button' => ['default' => '', 'items' => [ + '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'], + ]]], + ]); + } + + /** @param int $loads Counts how often the preset book is read. */ + private static function presets(int &$loads): PresetAssignment + { + return ComposeFixture::presetAssignment(null, null, null, null, static function () use (&$loads): PresetBook { + ++$loads; + + return self::book(); + }); + } + + /** + * @param list $blocks top-level blocks of the page + * @param list $touched + * @return array{0: list, 1: NodeIndex} + */ + private static function collect(array $blocks, array $touched, PresetAssignment $presets): array + { + $document = new Document(); + foreach ($blocks as $block) { + $document->append($block); + } + $index = NodeIndex::build($document); + $warnings = (new Warnings(DiviFixtures::designSystem(), $presets))->collect($index, $touched); + + return [ + array_values(array_filter($warnings, static fn (string $w): bool => str_contains($w, 'überdecken'))), + $index, + ]; + } + + public function testWarnsWhenLocalValuesCoverAnAssignedPreset(): void + { + $loads = 0; + $button = Block::create('divi/button', self::button('#000000')); + + [$covering, $index] = self::collect([$button], [$button], self::presets($loads)); + + self::assertCount(1, $covering); + self::assertStringStartsWith( + $index->idOf($button) . ': lokale Werte überdecken „CTA primär“: ', + $covering[0] + ); + self::assertStringContainsString('button.decoration.font.font', $covering[0]); + self::assertStringEndsWith('clear_overrides: true entfernt sie.', $covering[0]); + } + + public function testEqualValuesAndUntouchedBlocksDoNotWarn(): void + { + $loads = 0; + $same = Block::create('divi/button', self::button('#ffffff')); + $other = Block::create('divi/button', self::button('#000000')); + + [$covering] = self::collect([$same, $other], [$same], self::presets($loads)); + + self::assertSame([], $covering, 'the same value covers nothing; $other was not touched'); + } + + public function testChildrenOfATouchedBlockAreChecked(): void + { + $loads = 0; + $button = Block::create('divi/button', self::button('#000000')); + $section = Block::create('divi/section', [], [ + Block::create('divi/row', [], [Block::create('divi/column', [], [$button])]), + ]); + + [$covering, $index] = self::collect([$section], [$section], self::presets($loads)); + + self::assertCount(1, $covering); + self::assertStringStartsWith($index->idOf($button) . ': lokale Werte', $covering[0]); + } + + public function testThePresetBookIsOnlyReadForBlocksThatHavePresets(): void + { + $loads = 0; + $plain = Block::create('divi/button', [ + 'button' => ['decoration' => ['font' => ['font' => ['desktop' => ['value' => ['color' => '#000000']]]]]], + ]); + $presets = self::presets($loads); + + self::collect([$plain], [$plain], $presets); + self::assertSame(0, $loads, 'no touched block names a preset'); + + $grouped = Block::create('divi/button', [ + 'groupPreset' => ['button.decoration.font.font' => ['groupName' => 'divi/font', 'presetId' => ['g-1']]], + ]); + $stacked = Block::create('divi/button', self::button('#000000')); + [$covering] = self::collect([$plain, $grouped, $stacked], [$plain, $grouped, $stacked], $presets); + + self::assertSame(1, $loads, 'read once, for the first block that has a preset'); + self::assertCount(1, $covering); + } + + public function testOpaqueAndForeignBlocksAreSkipped(): void + { + $loads = 0; + $attrs = self::button('#000000'); + $opaque = Block::parsed( + 'divi/button', + $attrs + ['globalModule' => '9'], + '', + true, + true + ); + $foreign = Block::create('core/paragraph', $attrs); + + [$covering] = self::collect([$opaque, $foreign], [$opaque, $foreign], self::presets($loads)); + + self::assertSame([], $covering); + self::assertSame(0, $loads); + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php index 6aebcc0..fad65fb 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php @@ -59,7 +59,14 @@ final class DiviCompositionTest extends TestCase $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'], + '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'], @@ -238,4 +245,24 @@ final class DiviCompositionTest extends TestCase 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]); + } }