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:
co-authored by
Claude Sonnet 5.5
parent
763d765660
commit
d36e8286e8
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Design;
|
||||||
|
|
||||||
|
use JsonException;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Json;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mass assignment of a preset stack and group presets (lit/divi-presets-assign): every block of $module – optionally
|
||||||
|
* only those without an own preset (empty stack or only default IDs) or only those that use preset $onlyWithPresetId
|
||||||
|
* – gets $spec through PresetAssignment, the one choke point for preset assignments (D7). Local values that cover the
|
||||||
|
* assigned presets are noted, or removed with $clearOverrides (rulings R4, R5). A block that would not change stays
|
||||||
|
* untouched, so the call can be repeated. Pure PHP.
|
||||||
|
*/
|
||||||
|
final class AssignRewrite implements NodeRewrite
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array{preset?: mixed, group_presets?: mixed} $spec Already checked: PresetAssignment::apply() returned
|
||||||
|
* no errors for it.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private string $module,
|
||||||
|
private PresetAssignment $assignment,
|
||||||
|
private array $spec,
|
||||||
|
private bool $onlyWithoutPreset = false,
|
||||||
|
private ?string $onlyWithPresetId = null,
|
||||||
|
private bool $clearOverrides = false
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws LogicException When $spec does not pass PresetAssignment (it was to be validated beforehand). */
|
||||||
|
public function rewrite(Block $block): ?array
|
||||||
|
{
|
||||||
|
if (! $this->selects($block)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attrs = $block->attrs();
|
||||||
|
$result = $this->assignment->apply($this->module, $attrs, $this->spec, 'node');
|
||||||
|
if ($result['errors'] !== []) {
|
||||||
|
throw new LogicException('The preset assignment was not validated: ' . implode('; ', $result['errors']));
|
||||||
|
}
|
||||||
|
|
||||||
|
$new = $this->clearOverrides ? $this->assignment->clearOverrides($result['attrs']) : $result['attrs'];
|
||||||
|
|
||||||
|
return self::same($attrs, $new) ? null : $new;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notes(Block $block, string $nodeId): array
|
||||||
|
{
|
||||||
|
if ($this->clearOverrides) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->assignment->overrides()->describe($nodeId, $block->attrs(), $this->assignment->names());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function concernsInstance(Block $instance): bool
|
||||||
|
{
|
||||||
|
return GlobalInstance::effectiveName($instance) === $this->module;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function selects(Block $block): bool
|
||||||
|
{
|
||||||
|
if ($block->name() !== $this->module) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stack = NodeSummary::presetIds($block);
|
||||||
|
if ($this->onlyWithoutPreset && array_diff($stack, PresetUsageMatcher::DEFAULT_IDS) !== []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->onlyWithPresetId === null || in_array($this->onlyWithPresetId, $stack, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $before @param array<string, mixed> $after */
|
||||||
|
private static function same(array $before, array $after): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return Json::encode($before) === Json::encode($after);
|
||||||
|
} catch (JsonException) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Design;
|
||||||
|
|
||||||
|
use JsonException;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Json;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the references to preset $id from blocks (lit/divi-design-detach, ruling R10): a module preset from the
|
||||||
|
* modulePreset stack of the blocks of its module (an empty stack leaves no key, so Divi falls back to the default
|
||||||
|
* preset), a group preset from every groupPreset entry of its group (an empty entry, then an empty map is removed).
|
||||||
|
* Nested references inside other presets are not blocks; the preset pipeline removes those. Pure PHP.
|
||||||
|
*/
|
||||||
|
final class DetachRewrite implements NodeRewrite
|
||||||
|
{
|
||||||
|
public function __construct(private PresetScope $scope, private string $id)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rewrite(Block $block): ?array
|
||||||
|
{
|
||||||
|
if (! $block->isDivi()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->scope->kind === PresetScope::MODULE
|
||||||
|
? $this->withoutModulePreset($block)
|
||||||
|
: $this->withoutGroupPreset($block->attrs());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notes(Block $block, string $nodeId): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function concernsInstance(Block $instance): bool
|
||||||
|
{
|
||||||
|
return self::mentions($instance, $this->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed>|null */
|
||||||
|
private function withoutModulePreset(Block $block): ?array
|
||||||
|
{
|
||||||
|
$attrs = $block->attrs();
|
||||||
|
$stack = self::idList($attrs['modulePreset'] ?? null);
|
||||||
|
if ($block->name() !== $this->scope->name || ! in_array($this->id, $stack, true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rest = $this->without($stack);
|
||||||
|
if ($rest === []) {
|
||||||
|
unset($attrs['modulePreset']);
|
||||||
|
} else {
|
||||||
|
$attrs['modulePreset'] = $rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attrs
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function withoutGroupPreset(array $attrs): ?array
|
||||||
|
{
|
||||||
|
$groups = $attrs['groupPreset'] ?? null;
|
||||||
|
if (! is_array($groups)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed = false;
|
||||||
|
foreach ($groups as $groupId => $entry) {
|
||||||
|
if (! is_array($entry) || ($entry['groupName'] ?? null) !== $this->scope->name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ids = self::idList($entry['presetId'] ?? null);
|
||||||
|
if (! in_array($this->id, $ids, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed = true;
|
||||||
|
$rest = $this->without($ids);
|
||||||
|
if ($rest === []) {
|
||||||
|
unset($groups[$groupId]);
|
||||||
|
} else {
|
||||||
|
$groups[$groupId]['presetId'] = $rest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $changed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($groups === []) {
|
||||||
|
unset($attrs['groupPreset']);
|
||||||
|
} else {
|
||||||
|
$attrs['groupPreset'] = $groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<mixed> The IDs of a stored stack: a list, the legacy single string, else none. */
|
||||||
|
private static function idList(mixed $value): array
|
||||||
|
{
|
||||||
|
if (is_string($value)) {
|
||||||
|
return [$value];
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_array($value) ? array_values($value) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<array-key, mixed> $ids
|
||||||
|
* @return list<mixed>
|
||||||
|
*/
|
||||||
|
private function without(array $ids): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter($ids, fn (mixed $item): bool => $item !== $this->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the attributes of $block or of any block below mention $id (fail closed on unencodable values). */
|
||||||
|
private static function mentions(Block $block, string $id): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (str_contains(Json::encode($block->attrs()), $id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (JsonException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($block->children() as $child) {
|
||||||
|
if (self::mentions($child, $id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Design;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One change to the blocks of a tree (Etappe 4b): assigning a preset (AssignRewrite) or removing a reference to it
|
||||||
|
* (DetachRewrite). TreeRewrite applies it to every block of a page; the strategy only decides per block. Pure PHP.
|
||||||
|
*/
|
||||||
|
interface NodeRewrite
|
||||||
|
{
|
||||||
|
/** The new attributes of $block, or null when it stays as it is (then there is no note either). */
|
||||||
|
public function rewrite(Block $block): ?array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes on $block after rewrite() changed it (e.g. local values that cover the assigned preset).
|
||||||
|
*
|
||||||
|
* @param string $nodeId Path ID of $block (NodeIndex).
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function notes(Block $block, string $nodeId): array;
|
||||||
|
|
||||||
|
/** Whether the opaque global instance $instance would matter for this change (then it is reported as skipped). */
|
||||||
|
public function concernsInstance(Block $instance): bool;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Design;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a NodeRewrite to every block of a NodeIndex, in place (ruling R8): readable blocks are offered to
|
||||||
|
* rewrite(), a changed one gets its new attributes and then its notes; opaque blocks – global instances, blocks with
|
||||||
|
* unreadable attributes – are never offered and never changed, but listed as skipped when the strategy says the
|
||||||
|
* change concerns them (a global module is edited in its library entry). Pure PHP.
|
||||||
|
*/
|
||||||
|
final class TreeRewrite
|
||||||
|
{
|
||||||
|
public function __construct(private NodeRewrite $rewrite)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{changed: list<array{id: string, module: string, label: string}>, warnings: list<string>,
|
||||||
|
* skipped: list<array{id: string, reason: string}>}
|
||||||
|
*/
|
||||||
|
public function apply(NodeIndex $index): array
|
||||||
|
{
|
||||||
|
$changed = [];
|
||||||
|
$warnings = [];
|
||||||
|
$skipped = [];
|
||||||
|
foreach ($index->entries() as $entry) {
|
||||||
|
$id = $entry['id'];
|
||||||
|
$block = $entry['block'];
|
||||||
|
|
||||||
|
if ($block->isOpaque()) {
|
||||||
|
if ($this->rewrite->concernsInstance($block)) {
|
||||||
|
$skipped[] = ['id' => $id, 'reason' => self::reason($block)];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attrs = $this->rewrite->rewrite($block);
|
||||||
|
if ($attrs === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$block->setAttrs($attrs);
|
||||||
|
$changed[] = ['id' => $id, 'module' => $block->name(), 'label' => NodeSummary::label($block)];
|
||||||
|
array_push($warnings, ...$this->rewrite->notes($block, $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['changed' => $changed, 'warnings' => $warnings, 'skipped' => $skipped];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function reason(Block $block): string
|
||||||
|
{
|
||||||
|
$ref = NodeSummary::globalRef($block);
|
||||||
|
if ($ref !== null) {
|
||||||
|
return sprintf('globales Modul – im Bibliothekseintrag %s ändern', $ref['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $block->isGlobal()
|
||||||
|
? 'globales Modul ohne Bibliothekseintrag – im Divi-Builder prüfen'
|
||||||
|
: 'Attribute nicht lesbar – im Divi-Builder prüfen';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user