feat: lit/divi-design-detach entkoppelt Variablen – Verweise in Seiten und Presets werden durch den Wert ersetzt (V9)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 14:14:49 +02:00
co-authored by Claude Sonnet 5.5
parent 6e9218a2bc
commit e951cc1353
5 changed files with 358 additions and 44 deletions
@@ -8,14 +8,18 @@ use Brain\Monkey\Functions;
use Closure;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Design\DesignChange;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\DetachRewrite;
use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility;
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
use LIT\MCP\Integrations\Divi\Design\PageRewriter;
use LIT\MCP\Integrations\Divi\Design\PresetBook;
use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery;
use LIT\MCP\Integrations\Divi\Design\ReferenceUsageQuery;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Design\VariableDetachRewrite;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
@@ -33,6 +37,10 @@ final class DiviDesignDetachAbilityTest extends TestCase
{
private const LESETEXT = ['group' => 'font-body', 'preset' => 'Lesetext'];
private const RADIUS = 'gvid-radius0001';
private const CLAIM = 'gvid-claim00001';
protected function setUp(): void
{
parent::setUp();
@@ -48,13 +56,15 @@ final class DiviDesignDetachAbilityTest extends TestCase
?SnapshotStore $snapshots = null,
?FakeChangeLog $log = null,
?UsageReport $report = null,
bool $unfiltered = true
bool $unfiltered = true,
?GlobalVariablesAdapter $variables = null
): DiviDesignDetachAbility {
$usages = Mockery::mock(GlobalUsages::class);
$usages->allows('reportFor')->andReturn($report ?? new UsageReport(0, [], 0, 0, [], 0, false));
return new DiviDesignDetachAbility(
$writer ?? self::readOnlyWriter(),
$variables ?? self::variableAdapter(),
new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), $log ?? FakeChangeLog::forbidden()),
$usages,
$pages,
@@ -77,6 +87,64 @@ final class DiviDesignDetachAbilityTest extends TestCase
return $writer;
}
/** Reads variables() (the Variablen-Verwaltung is allowed unless $manage is false), never saves. */
private static function variableAdapter(bool $manage = true): GlobalVariablesAdapter&MockInterface
{
$variables = Mockery::mock(GlobalVariablesAdapter::class);
$variables->allows('read')->andReturn(self::variables());
$variables->allows('canManage')->andReturn($manage);
$variables->expects('save')->never();
return $variables;
}
/** @return array<string, array<string, array<string, mixed>>> Global variables in Divi's stored form. */
private static function variables(): array
{
return [
'numbers' => [self::RADIUS => [
'id' => self::RADIUS, 'label' => 'Eckenradius', 'value' => '8px', 'order' => '1',
'status' => 'active', 'lastUpdated' => '2026-10-01T10:00:00.000Z', 'variableType' => 'numbers',
]],
// Archived or not does not matter for a detach (V9).
'strings' => [self::CLAIM => [
'id' => self::CLAIM, 'label' => 'Claim', 'value' => 'Gut beraten', 'order' => '',
'status' => 'archived', 'lastUpdated' => '2026-10-01T10:00:00.000Z', 'variableType' => 'strings',
]],
];
}
/** @return array<string, mixed> PresetBookTest::stored() with the radius variable in preset "CTA primär". */
private static function storedWithVariable(): array
{
$stored = PresetBookTest::stored();
$radius = ['topLeft' => VariableDetachRewriteTest::ref(self::RADIUS)];
$stored['module']['divi/button']['items']['aaaaaaaaaa']['attrs'] = ['button' => ['decoration' => [
'border' => ['desktop' => ['value' => ['radius' => $radius]]],
]]];
return $stored;
}
/** Matches the reference query of a preview()/apply() call: it finds $id and nothing else. */
private static function referencing(string $id): Closure
{
return static fn (ReferenceUsageQuery $query): bool => $query->matches(
'<!-- wp:divi/text {"x":"' . $id . '"} /-->'
) && ! $query->matches('<!-- wp:divi/text {"x":"gvid-andere0001"} /-->');
}
/** Matches a VariableDetachRewrite strategy that turns a whole reference to $id into $expected. */
private static function resolving(string $id, string $expected, array $settings = []): Closure
{
return static function (Closure $rewrite) use ($id, $expected, $settings): bool {
$strategy = $rewrite();
$block = Block::create('divi/text', ['x' => VariableDetachRewriteTest::ref($id, $settings)]);
return $strategy instanceof VariableDetachRewrite && $strategy->rewrite($block) === ['x' => $expected];
};
}
private static function untouched(): PageRewriter&MockInterface
{
$pages = Mockery::mock(PageRewriter::class);
@@ -110,14 +178,158 @@ final class DiviDesignDetachAbilityTest extends TestCase
self::assertToolError($ability->execute(['preset' => self::LESETEXT]), 'forbidden');
}
public function testVariablesFollowWithStage4c(): void
public function testAVariableNeedsTheVariableRightsToo(): void
{
$error = self::assertToolError(
$this->ability(self::untouched())->execute(['variable' => 'Eckenradius']),
'unsupported'
);
$ability = $this->ability(self::untouched(), null, null, null, null, true, self::variableAdapter(false));
self::assertStringContainsString('Variablen folgen mit Etappe 4c.', $error['hint']);
self::assertToolError($ability->permission(['variable' => 'Eckenradius']), 'forbidden');
self::assertToolError($ability->execute(['variable' => 'Eckenradius']), 'forbidden');
self::assertTrue($ability->permission(['preset' => self::LESETEXT]));
}
public function testUnknownAmbiguousAndColorVariables(): void
{
$ability = $this->ability(self::untouched());
$unknown = self::assertToolError($ability->execute(['variable' => 'Fehlt']), 'not_found');
self::assertStringContainsString('lit/divi-colors-set', $unknown['hint']);
$color = self::assertToolError($ability->execute(['variable' => 'gcid-primary-color']), 'unsupported');
self::assertStringContainsString('nur deaktivieren', $color['hint']);
self::assertArrayHasKey('variable', self::errorFields($ability->execute(['variable' => ' '])));
$both = $ability->execute(['variable' => 'Eckenradius', 'preset' => self::LESETEXT]);
self::assertSame('entweder preset oder variable angeben, nicht beides', self::errorFields($both)['variable']);
}
public function testVariableDryRunListsPagesAndThePresetsThatUseIt(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('preview')
->with(
Mockery::on(self::referencing(self::RADIUS)),
Mockery::on(self::resolving(self::RADIUS, '8px')),
null
)
->andReturn(PageRewriteReportTest::preview(1));
$pages->expects('apply')->never();
$ability = $this->ability($pages, self::readOnlyWriter(self::storedWithVariable()));
$dry = self::assertToolError($ability->execute(['variable' => 'eckenradius']), 'confirm_required');
self::assertSame(12, $dry['pages'][0]['post_id']);
self::assertSame([['aaaaaaaaaa', 'changed']], array_map(
static fn (array $row): array => [$row['id'], $row['action']],
$dry['diff']
));
self::assertSame(PresetBook::fromDivi(self::storedWithVariable())->rev(), $dry['design_rev']);
self::assertStringStartsWith('Entkoppeln von Variable „Eckenradius“ – betroffen', $dry['message']);
self::assertStringContainsString('die Verweise in 1 Presets (diff, mit Snapshot)', $dry['message']);
}
public function testAStringsVariableKeepsBeforeAndAfterAndArchivedOnesWork(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('preview')
->with(
Mockery::on(self::referencing(self::CLAIM)),
Mockery::on(self::resolving(self::CLAIM, '„Gut beraten“', ['before' => '„', 'after' => '“'])),
null
)
->andReturn(PageRewriteReportTest::preview(1));
$dry = self::assertToolError($this->ability($pages)->execute(['variable' => self::CLAIM]), 'confirm_required');
self::assertArrayNotHasKey('diff', $dry, 'no preset uses the claim');
}
public function testVariableConfirmResolvesThePresetsFirstThenThePages(): void
{
$calls = [];
$stored = self::storedWithVariable();
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('presets')->andReturn($stored);
$writer->allows('canUseVisualBuilder')->andReturn(true);
$writer->expects('savePresets')
->with(
Mockery::on(static fn (array $rest): bool => ($rest['module'][0]['items'][0]['attrs']['button']
['decoration']['border']['desktop']['value']['radius']['topLeft'] ?? null) === '8px'),
false
)
->andReturnUsing(static function () use (&$calls, $stored): array {
$calls[] = 'presets';
return PresetBook::fromDivi($stored)->withVariableResolved(self::RADIUS, '8px')->data();
});
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->with('presets', $stored, 'lit/divi-design-detach')->andReturn('presets-5');
$snapshots->expects('attachLog');
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('apply')
->with(
'lit/divi-design-detach',
Mockery::on(self::referencing(self::RADIUS)),
Mockery::on(self::resolving(self::RADIUS, '8px')),
null,
'Entkoppeln von Variable „Eckenradius“'
)
->andReturnUsing(static function () use (&$calls): array {
$calls[] = 'pages';
return PageRewriteReportTest::applied(1, 0);
});
$ability = $this->ability($pages, $writer, $snapshots, new FakeChangeLog(9));
$result = $ability->execute([
'variable' => self::RADIUS,
'confirm' => true,
'design_rev' => PresetBook::fromDivi($stored)->rev(),
]);
self::assertSame(['presets', 'pages'], $calls);
self::assertSame('presets-5', $result['snapshot_id']);
self::assertSame('aaaaaaaaaa', $result['diff'][0]['id']);
self::assertStringStartsWith('Presets: 1 geändert.', $result['summary']);
self::assertStringEndsWith(
'Jetzt kann lit/divi-variables-set die Variable archivieren (status: archived).',
$result['summary']
);
}
public function testVariablePostIdsSkipThePresetStep(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('apply')
->with('lit/divi-design-detach', Mockery::any(), Mockery::any(), [12], Mockery::type('string'))
->andReturn(PageRewriteReportTest::applied(1, 0));
$ability = $this->ability($pages, self::readOnlyWriter(self::storedWithVariable()));
$result = $ability->execute(['variable' => 'Eckenradius', 'post_ids' => [12], 'confirm' => true]);
self::assertSame([], $result['diff']);
self::assertNull($result['snapshot_id']);
self::assertStringNotContainsString('lit/divi-variables-set', $result['summary']);
}
public function testNoVariableReferenceLeftPointsToArchiving(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('preview')->andReturn(PageRewriteReportTest::preview(0));
$result = $this->ability($pages)->execute(['variable' => 'Eckenradius']);
self::assertSame(
'Keine Verweise auf Variable „Eckenradius“ gefunden – nichts zu tun. Jetzt kann lit/divi-variables-set '
. 'die Variable archivieren (status: archived).',
$result['summary']
);
}
public function testFeaturesPerBranch(): void
{
$ability = $this->ability(self::untouched());
self::assertNotContains(DiviFeature::VariableWrite, $ability->diviFeatures(['preset' => self::LESETEXT]));
self::assertContains(DiviFeature::VariableWrite, $ability->diviFeatures(['variable' => 'Eckenradius']));
self::assertContains(DiviFeature::PresetWrite, $ability->diviFeatures(['variable' => 'Eckenradius']));
}
public function testUnknownAndAmbiguousPresets(): void