feat: Umschreib-Strategien für Preset-Zuweisung und -Entkopplung (TreeRewrite, AssignRewrite, DetachRewrite)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 16:57:33 +02:00
co-authored by Claude Sonnet 5.5
parent 763d765660
commit d36e8286e8
7 changed files with 787 additions and 0 deletions
@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Design\AssignRewrite;
use LIT\MCP\Integrations\Divi\Design\PresetAssignment;
use LIT\MCP\Integrations\Divi\Design\PresetBook;
use LogicException;
use PHPUnit\Framework\TestCase;
final class AssignRewriteTest extends TestCase
{
private const BUTTON = ['module' => 'divi/button', 'preset' => 'aaaaaaaaaa'];
/** PresetBookTest::stored() with settings in "CTA primär" (aaaaaaaaaa): background colour #111111. */
private static function stored(): array
{
$stored = PresetBookTest::stored();
$stored['module']['divi/button']['items']['aaaaaaaaaa']['attrs'] = [
'button' => ['decoration' => ['background' => ['desktop' => ['value' => ['color' => '#111111']]]]],
];
return $stored;
}
private static function assignment(): PresetAssignment
{
return new PresetAssignment(
static fn (): PresetBook => PresetBook::fromDivi(self::stored()),
static fn (string $module): array => $module === 'divi/text'
? ['content.decoration.bodyFont' => 'divi/font-body']
: [],
static fn (string $id): bool => preg_match('/^[a-z0-9]{10}$/D', $id) === 1
);
}
/** @return NodeIndex Blocks below s0.r0.c0 are m0, m1, … in the order of $inner. */
private static function index(string $inner): NodeIndex
{
return NodeIndex::build((new Parser())->parse(
'<!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->' . $inner
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->'
));
}
private static function node(NodeIndex $index, string $id): Block
{
$block = $index->find('s0.r0.c0.' . $id);
self::assertNotNull($block);
return $block;
}
public function testAssignsTheStackToBlocksOfTheModuleOnly(): void
{
$index = self::index('<!-- wp:divi/button /--><!-- wp:divi/text /-->');
$rewrite = new AssignRewrite('divi/button', self::assignment(), ['preset' => ['aaaaaaaaaa', 'bbbbbbbbbb']]);
$button = $rewrite->rewrite(self::node($index, 'm0'));
self::assertSame(['aaaaaaaaaa', 'bbbbbbbbbb'], $button['modulePreset'] ?? null);
self::assertNull($rewrite->rewrite(self::node($index, 'm1')), 'another module');
}
public function testOnlyWithoutPresetSkipsBlocksWithAnExplicitPreset(): void
{
$index = self::index(
'<!-- wp:divi/button {"modulePreset":["bbbbbbbbbb"]} /-->'
. '<!-- wp:divi/button /-->'
. '<!-- wp:divi/button {"modulePreset":["default"]} /-->'
);
$rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON, onlyWithoutPreset: true);
self::assertNull($rewrite->rewrite(self::node($index, 'm0')));
self::assertSame(['aaaaaaaaaa'], $rewrite->rewrite(self::node($index, 'm1'))['modulePreset'] ?? null);
self::assertSame(['aaaaaaaaaa'], $rewrite->rewrite(self::node($index, 'm2'))['modulePreset'] ?? null);
}
public function testOnlyWithPresetIdReplacesThatPreset(): void
{
$index = self::index(
'<!-- wp:divi/button {"modulePreset":["bbbbbbbbbb"]} /-->'
. '<!-- wp:divi/button {"modulePreset":["cccccccccc"]} /-->'
. '<!-- wp:divi/button /-->'
);
$rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON, onlyWithPresetId: 'bbbbbbbbbb');
self::assertSame(['aaaaaaaaaa'], $rewrite->rewrite(self::node($index, 'm0'))['modulePreset'] ?? null);
self::assertNull($rewrite->rewrite(self::node($index, 'm1')), 'another preset');
self::assertNull($rewrite->rewrite(self::node($index, 'm2')), 'no preset at all');
}
public function testBothFiltersMustHold(): void
{
$index = self::index('<!-- wp:divi/button {"modulePreset":["bbbbbbbbbb"]} /-->');
$rewrite = new AssignRewrite(
'divi/button',
self::assignment(),
self::BUTTON,
onlyWithoutPreset: true,
onlyWithPresetId: 'bbbbbbbbbb'
);
self::assertNull($rewrite->rewrite(self::node($index, 'm0')));
}
public function testRunningAgainChangesNothing(): void
{
$index = self::index('<!-- wp:divi/button {"modulePreset":["aaaaaaaaaa"],"x":1} /-->');
$rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON);
self::assertNull($rewrite->rewrite(self::node($index, 'm0')));
}
public function testGroupPresetsAreAssignedThroughTheKeyOfTheGroup(): void
{
$index = self::index('<!-- wp:divi/text /-->');
$rewrite = new AssignRewrite(
'divi/text',
self::assignment(),
['group_presets' => ['font-body' => 'Lesetext']]
);
$attrs = $rewrite->rewrite(self::node($index, 'm0'));
self::assertSame(
['presetId' => ['gggggggggg'], 'groupName' => 'divi/font-body'],
$attrs['groupPreset']['content.decoration.bodyFont'] ?? null
);
self::assertArrayNotHasKey('modulePreset', $attrs);
}
public function testNotesDescribeWhatTheAssignedPresetCovers(): void
{
$index = self::index(
'<!-- wp:divi/button {"button":{"decoration":{"background":{"desktop":{"value":{"color":"#ff0000"}}}}}} /-->'
. '<!-- wp:divi/button {"button":{"decoration":{"background":{"desktop":{"value":{"color":"#111111"}}}}}} /-->'
);
$rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON);
$different = self::node($index, 'm0');
$different->setAttrs($rewrite->rewrite($different) ?? []);
$notes = $rewrite->notes($different, 's0.r0.c0.m0');
self::assertCount(1, $notes);
self::assertStringContainsString('s0.r0.c0.m0: lokale Werte überdecken „CTA primär“', $notes[0]);
self::assertStringContainsString('clear_overrides: true entfernt sie.', $notes[0]);
$same = self::node($index, 'm1');
$same->setAttrs($rewrite->rewrite($same) ?? []);
self::assertSame([], $rewrite->notes($same, 's0.r0.c0.m1'), 'the same value covers nothing');
}
public function testClearOverridesRemovesTheCoveredValuesAndHasNoNotes(): void
{
$index = self::index(
'<!-- wp:divi/button {"button":{"decoration":{"background":{"desktop":{"value":{"color":"#ff0000"}}}}}} /-->'
);
$rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON, clearOverrides: true);
$block = self::node($index, 'm0');
$attrs = $rewrite->rewrite($block);
self::assertSame(['aaaaaaaaaa'], $attrs['modulePreset'] ?? null);
self::assertArrayNotHasKey('button', $attrs, 'the local value and its empty parents are gone');
$block->setAttrs($attrs);
self::assertSame([], $rewrite->notes($block, 's0.r0.c0.m0'));
}
public function testClearOverridesAloneAlsoChangesABlockThatAlreadyHasThePreset(): void
{
$index = self::index(
'<!-- wp:divi/button {"modulePreset":["aaaaaaaaaa"],"button":{"decoration":{"background":'
. '{"desktop":{"value":{"color":"#ff0000"}}}}}} /-->'
);
$rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON, clearOverrides: true);
$attrs = $rewrite->rewrite(self::node($index, 'm0'));
self::assertSame(['modulePreset' => ['aaaaaaaaaa']], $attrs);
}
public function testAnUnvalidatedSpecIsALogicError(): void
{
$index = self::index('<!-- wp:divi/button /-->');
$rewrite = new AssignRewrite('divi/button', self::assignment(), ['preset' => 'Gibt es nicht']);
$this->expectException(LogicException::class);
$rewrite->rewrite(self::node($index, 'm0'));
}
public function testGlobalInstancesOfTheModuleConcernTheRewrite(): void
{
$index = self::index(
'<!-- wp:divi/global-layout {"globalModule":"1058","blockName":"divi/button"} /-->'
. '<!-- wp:divi/global-layout {"globalModule":"1059","blockName":"divi/text"} /-->'
);
$rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON);
self::assertTrue($rewrite->concernsInstance(self::node($index, 'g0')));
self::assertFalse($rewrite->concernsInstance(self::node($index, 'g1')));
}
}
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Design\DetachRewrite;
use LIT\MCP\Integrations\Divi\Design\PresetScope;
use PHPUnit\Framework\TestCase;
final class DetachRewriteTest extends TestCase
{
/** @return NodeIndex Blocks below s0.r0.c0 are m0, m1, … (g0, … for instances) in the order of $inner. */
private static function index(string $inner): NodeIndex
{
return NodeIndex::build((new Parser())->parse(
'<!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->' . $inner
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->'
));
}
private static function node(NodeIndex $index, string $id): Block
{
$block = $index->find('s0.r0.c0.' . $id);
self::assertNotNull($block);
return $block;
}
public function testRemovesAModulePresetFromTheStack(): void
{
$index = self::index(
'<!-- wp:divi/button {"modulePreset":["aaaaaaaaaa","bbbbbbbbbb"]} /-->'
. '<!-- wp:divi/button {"modulePreset":["aaaaaaaaaa"],"x":1} /-->'
. '<!-- wp:divi/text {"modulePreset":["aaaaaaaaaa"]} /-->'
. '<!-- wp:divi/button {"modulePreset":"aaaaaaaaaa"} /-->'
. '<!-- wp:divi/button {"modulePreset":["cccccccccc"]} /-->'
);
$rewrite = new DetachRewrite(new PresetScope('module', 'divi/button'), 'aaaaaaaaaa');
self::assertSame(['modulePreset' => ['bbbbbbbbbb']], $rewrite->rewrite(self::node($index, 'm0')));
self::assertSame(['x' => 1], $rewrite->rewrite(self::node($index, 'm1')), 'an empty stack leaves no key');
self::assertNull($rewrite->rewrite(self::node($index, 'm2')), 'another module');
self::assertSame([], $rewrite->rewrite(self::node($index, 'm3')), 'legacy string form');
self::assertNull($rewrite->rewrite(self::node($index, 'm4')), 'the ID is not in the stack');
}
public function testRemovesAGroupPresetFromEveryEntryOfTheGroup(): void
{
$index = self::index(
'<!-- wp:divi/text {"groupPreset":{'
. '"content.decoration.bodyFont":{"presetId":["gggggggggg","hhhhhhhhhh"],"groupName":"divi/font-body"},'
. '"module.decoration.spacing":{"presetId":["gggggggggg"],"groupName":"divi/spacing"}}} /-->'
. '<!-- wp:divi/text {"groupPreset":{"content.decoration.bodyFont":'
. '{"presetId":["gggggggggg"],"groupName":"divi/font-body"}},"x":1} /-->'
. '<!-- wp:divi/text {"groupPreset":{"module.decoration.spacing":'
. '{"presetId":["gggggggggg"],"groupName":"divi/spacing"}}} /-->'
. '<!-- wp:core/paragraph {"groupPreset":{"content.decoration.bodyFont":'
. '{"presetId":["gggggggggg"],"groupName":"divi/font-body"}}} /-->'
);
$rewrite = new DetachRewrite(new PresetScope('group', 'divi/font-body'), 'gggggggggg');
$first = $rewrite->rewrite(self::node($index, 'm0'));
self::assertSame(['hhhhhhhhhh'], $first['groupPreset']['content.decoration.bodyFont']['presetId'] ?? null);
self::assertSame(
['presetId' => ['gggggggggg'], 'groupName' => 'divi/spacing'],
$first['groupPreset']['module.decoration.spacing'] ?? null,
'another group keeps its preset'
);
self::assertSame(['x' => 1], $rewrite->rewrite(self::node($index, 'm1')), 'empty entry and map are removed');
self::assertNull($rewrite->rewrite(self::node($index, 'm2')), 'the ID sits in another group');
self::assertNull($rewrite->rewrite(self::node($index, 'x0')), 'not a Divi block');
}
public function testAnUntouchedBlockStaysNull(): void
{
$index = self::index('<!-- wp:divi/button /--><!-- wp:divi/text {"groupPreset":{}} /-->');
self::assertNull(
(new DetachRewrite(new PresetScope('module', 'divi/button'), 'aaaaaaaaaa'))->rewrite(self::node($index, 'm0'))
);
self::assertNull(
(new DetachRewrite(new PresetScope('group', 'divi/font-body'), 'gggggggggg'))->rewrite(self::node($index, 'm1'))
);
}
public function testNotesAreEmpty(): void
{
$index = self::index('<!-- wp:divi/button /-->');
self::assertSame(
[],
(new DetachRewrite(new PresetScope('module', 'divi/button'), 'aaaaaaaaaa'))
->notes(self::node($index, 'm0'), 's0.r0.c0.m0')
);
}
public function testInstancesThatMentionTheIdConcernTheRewrite(): void
{
$index = self::index(
'<!-- wp:divi/global-layout {"globalModule":"1058","blockName":"divi/button",'
. '"localAttrs":{"modulePreset":["aaaaaaaaaa"]}} /-->'
. '<!-- wp:divi/global-layout {"globalModule":"1059","blockName":"divi/section"} -->'
. '<!-- wp:divi/button {"modulePreset":["aaaaaaaaaa"],"globalParent":"1059"} /-->'
. '<!-- /wp:divi/global-layout -->'
);
$rewrite = new DetachRewrite(new PresetScope('module', 'divi/button'), 'aaaaaaaaaa');
$other = new DetachRewrite(new PresetScope('module', 'divi/button'), 'bbbbbbbbbb');
self::assertTrue($rewrite->concernsInstance(self::node($index, 'g0')), 'localAttrs');
self::assertTrue($rewrite->concernsInstance(self::node($index, 'g1')), 'snapshot of a child');
self::assertFalse($other->concernsInstance(self::node($index, 'g0')));
}
}
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Document;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Design\NodeRewrite;
use LIT\MCP\Integrations\Divi\Design\TreeRewrite;
use LIT\MCP\Tests\Unit\TestCase;
final class TreeRewriteTest extends TestCase
{
private static function document(string $inner): Document
{
return (new Parser())->parse(
'<!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->' . $inner
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->'
);
}
/** Marks every divi/button; refuses opaque blocks, like the real strategies must never be offered one. */
private static function touchButtons(): NodeRewrite
{
return new class implements NodeRewrite {
public function rewrite(Block $block): ?array
{
if ($block->isOpaque()) {
throw new \LogicException('An opaque block was offered to rewrite().');
}
return $block->name() === 'divi/button' ? $block->attrs() + ['touched' => true] : null;
}
public function notes(Block $block, string $nodeId): array
{
return ['Hinweis ' . $nodeId];
}
public function concernsInstance(Block $instance): bool
{
return GlobalInstance::effectiveName($instance) === 'divi/button';
}
};
}
public function testChangesBlocksAndReportsThem(): void
{
$document = self::document(
'<!-- wp:divi/button {"module":{"meta":{"adminLabel":{"desktop":{"value":"Kaufen"}}}}} /-->'
. '<!-- wp:divi/text /--><!-- wp:divi/button /-->'
);
$result = (new TreeRewrite(self::touchButtons()))->apply(NodeIndex::build($document));
self::assertSame([
['id' => 's0.r0.c0.m0', 'module' => 'divi/button', 'label' => 'Kaufen'],
['id' => 's0.r0.c0.m2', 'module' => 'divi/button', 'label' => ''],
], $result['changed']);
self::assertSame(['Hinweis s0.r0.c0.m0', 'Hinweis s0.r0.c0.m2'], $result['warnings']);
self::assertSame([], $result['skipped']);
self::assertSame(2, substr_count(Serializer::document($document), '"touched":true'));
}
public function testNothingToDoChangesNothing(): void
{
$document = self::document('<!-- wp:divi/text {"a":1} /-->');
$before = Serializer::document($document);
$result = (new TreeRewrite(self::touchButtons()))->apply(NodeIndex::build($document));
self::assertSame(['changed' => [], 'warnings' => [], 'skipped' => []], $result);
self::assertSame($before, Serializer::document($document));
}
public function testGlobalInstancesAreSkippedWithTheLibraryEntry(): void
{
$document = self::document(
'<!-- wp:divi/global-layout {"globalModule":"1058","blockName":"divi/button"} /-->'
. '<!-- wp:divi/global-layout {"globalModule":"1059","blockName":"divi/text"} /-->'
);
$before = Serializer::document($document);
$result = (new TreeRewrite(self::touchButtons()))->apply(NodeIndex::build($document));
self::assertSame([], $result['changed']);
self::assertSame(
[['id' => 's0.r0.c0.g0', 'reason' => 'globales Modul – im Bibliothekseintrag 1058 ändern']],
$result['skipped']
);
self::assertSame($before, Serializer::document($document), 'instances are never rewritten');
}
public function testBlocksWithUnreadableAttributesAreSkippedToo(): void
{
$document = self::document('<!-- wp:divi/button {"a":1,} /-->');
$result = (new TreeRewrite(self::touchButtons()))->apply(NodeIndex::build($document));
self::assertSame(
[['id' => 's0.r0.c0.m0', 'reason' => 'Attribute nicht lesbar – im Divi-Builder prüfen']],
$result['skipped']
);
}
public function testAGlobalLayoutWithoutLibraryEntryHasItsOwnReason(): void
{
$document = self::document('<!-- wp:divi/global-layout {"blockName":"divi/button"} /-->');
$result = (new TreeRewrite(self::touchButtons()))->apply(NodeIndex::build($document));
self::assertSame(
[['id' => 's0.r0.c0.g0', 'reason' => 'globales Modul ohne Bibliothekseintrag – im Divi-Builder prüfen']],
$result['skipped']
);
}
}