feat: lit/divi-design-detach – Preset-Verweise aus Seiten und verschachtelten Presets entfernen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 17:09:49 +02:00
co-authored by Claude Opus 5.5
parent d4f235ae5e
commit 607e0a21ca
6 changed files with 813 additions and 0 deletions
@@ -0,0 +1,325 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use Closure;
use LIT\MCP\Abilities\Errors;
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\PageRewriter;
use LIT\MCP\Integrations\Divi\Design\PresetBook;
use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Mockery\MockInterface;
use ReflectionProperty;
final class DiviDesignDetachAbilityTest extends TestCase
{
private const LESETEXT = ['group' => 'font-body', 'preset' => 'Lesetext'];
protected function setUp(): void
{
parent::setUp();
Functions\when('current_user_can')->justReturn(true);
// DesignLock (final review I-5): the lock is always free here.
Functions\when('add_option')->justReturn(true);
Functions\when('delete_option')->justReturn(true);
}
private function ability(
PageRewriter $pages,
?DesignSystemWriter $writer = null,
?SnapshotStore $snapshots = null,
?FakeChangeLog $log = null
): DiviDesignDetachAbility {
$usages = Mockery::mock(GlobalUsages::class);
$usages->allows('reportFor')->andReturn(new UsageReport(0, [], 0, 0, [], 0, false));
return new DiviDesignDetachAbility(
$writer ?? self::readOnlyWriter(),
new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), $log ?? FakeChangeLog::forbidden()),
$usages,
$pages,
static fn (): bool => true
);
}
/** Reads PresetBookTest::stored(), never saves. */
private static function readOnlyWriter(): DesignSystemWriter&MockInterface
{
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('presets')->andReturn(PresetBookTest::stored());
$writer->allows('canUseVisualBuilder')->andReturn(true);
$writer->expects('savePresets')->never();
return $writer;
}
private static function untouched(): PageRewriter&MockInterface
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('preview')->never();
$pages->expects('apply')->never();
return $pages;
}
/** Matches the explicit-only query and a DetachRewrite strategy of a preview()/apply() call. */
private static function explicitQuery(): Closure
{
return static function (PresetUsageQuery $query): bool {
$targets = (new ReflectionProperty(PresetUsageQuery::class, 'targets'))->getValue($query);
return array_column($targets, 'default') === [false];
};
}
private static function detaching(): Closure
{
return static fn (Closure $rewrite): bool => $rewrite() instanceof DetachRewrite;
}
public function testNeedsEditThemeOptionsAndTheVisualBuilder(): void
{
$ability = $this->ability(self::untouched());
Functions\when('current_user_can')->justReturn(false);
self::assertToolError($ability->permission(['preset' => self::LESETEXT]), 'forbidden');
self::assertToolError($ability->execute(['preset' => self::LESETEXT]), 'forbidden');
}
public function testVariablesFollowWithStage4c(): void
{
$error = self::assertToolError(
$this->ability(self::untouched())->execute(['variable' => 'Eckenradius']),
'unsupported'
);
self::assertStringContainsString('Variablen folgen mit Etappe 4c.', $error['hint']);
}
public function testUnknownAndAmbiguousPresets(): void
{
$ability = $this->ability(self::untouched());
$unknown = $ability->execute(['preset' => ['module' => 'button', 'preset' => 'Fehlt']]);
self::assertToolError($unknown, 'not_found');
$ambiguous = $ability->execute(['preset' => ['module' => 'button', 'preset' => 'Doppelt']]);
self::assertToolError($ambiguous, 'validation_failed');
self::assertStringContainsString('bbbbbbbbbb, cccccccccc', self::errorFields($ambiguous)['preset.preset']);
$form = $ability->execute(['preset' => ['preset' => 'Lesetext']]);
self::assertArrayHasKey('preset', self::errorFields($form));
}
public function testDryRunListsPagesAndTheDiffOfTheNestingPresets(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('preview')
->with(Mockery::on(self::explicitQuery()), Mockery::on(self::detaching()), null)
->andReturn(PageRewriteReportTest::preview(1));
$pages->expects('apply')->never();
$dry = self::assertToolError($this->ability($pages)->execute(['preset' => self::LESETEXT]), 'confirm_required');
self::assertSame(12, $dry['pages'][0]['post_id']);
self::assertSame([['dddddddddd', 'changed']], array_map(
static fn (array $row): array => [$row['id'], $row['action']],
$dry['diff']
));
self::assertSame(PresetBook::fromDivi(PresetBookTest::stored())->rev(), $dry['design_rev']);
self::assertStringContainsString('design_rev', $dry['message']);
self::assertSame([], $dry['warnings']);
}
public function testConfirmWithAStaleDesignRevWritesNeitherPresetsNorPages(): void
{
$error = self::assertToolError(
$this->ability(self::untouched())->execute([
'preset' => self::LESETEXT,
'confirm' => true,
'design_rev' => 'veraltet',
]),
'rev_mismatch'
);
self::assertStringContainsString('nichts wurde gespeichert', $error['message']);
}
public function testConfirmRemovesTheNestedReferencesFirstThenThePages(): void
{
$calls = [];
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('presets')->andReturn(PresetBookTest::stored());
$writer->allows('canUseVisualBuilder')->andReturn(true);
$writer->expects('savePresets')
->with(
Mockery::on(static fn (array $rest): bool => $rest['module'][1]['items'][0]['groupPresets'] === []),
false
)
->andReturnUsing(static function () use (&$calls): array {
$calls[] = 'presets';
return PresetBook::fromDivi(PresetBookTest::stored())->withoutNestedRef('gggggggggg')->data();
});
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->andReturn('presets-4');
$snapshots->expects('attachLog');
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('preview')->andReturn(PageRewriteReportTest::preview(1));
$pages->expects('apply')
->with(
'lit/divi-design-detach',
Mockery::on(self::explicitQuery()),
Mockery::on(self::detaching()),
null,
'Entkoppeln von Gruppen-Preset divi/font-body „Lesetext“'
)
->andReturnUsing(static function () use (&$calls): array {
$calls[] = 'pages';
return PageRewriteReportTest::applied(1, 0);
});
$ability = $this->ability($pages, $writer, $snapshots, new FakeChangeLog(7));
$dry = self::assertToolError($ability->execute(['preset' => self::LESETEXT]), 'confirm_required');
$result = $ability->execute([
'preset' => self::LESETEXT,
'confirm' => true,
'design_rev' => $dry['design_rev'],
]);
self::assertSame(['presets', 'pages'], $calls);
self::assertSame('presets-4', $result['snapshot_id']);
self::assertSame(7, $result['log_id']);
self::assertSame('dddddddddd', $result['diff'][0]['id']);
self::assertSame(12, $result['saved'][0]['post_id']);
self::assertStringStartsWith('Presets: 1 geändert.', $result['summary']);
self::assertStringEndsWith('Jetzt kann lit/divi-presets-delete das Preset löschen.', $result['summary']);
}
public function testAPageErrorAfterThePresetStepSaysThePresetsAreChanged(): void
{
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('presets')->andReturn(PresetBookTest::stored());
$writer->allows('canUseVisualBuilder')->andReturn(true);
$writer->expects('savePresets')
->andReturn(PresetBook::fromDivi(PresetBookTest::stored())->withoutNestedRef('gggggggggg')->data());
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->andReturn('presets-4');
$snapshots->expects('attachLog');
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('apply')->andReturn(Errors::partialWrite('Seite 13 nicht gespeichert.', 13));
$rev = PresetBook::fromDivi(PresetBookTest::stored())->rev();
$error = self::assertToolError(
$this->ability($pages, $writer, $snapshots, new FakeChangeLog(7))->execute([
'preset' => self::LESETEXT,
'confirm' => true,
'design_rev' => $rev,
]),
'partial_write'
);
self::assertStringContainsString('bereits entfernt (Snapshot presets-4)', $error['message']);
self::assertSame('presets-4', $error['snapshot_id']);
self::assertSame(13, $error['post_id']);
}
public function testPostIdsTouchOnlyThePages(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('preview')
->with(Mockery::on(self::explicitQuery()), Mockery::on(self::detaching()), [12])
->andReturn(PageRewriteReportTest::preview(1));
$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);
$input = ['preset' => self::LESETEXT, 'post_ids' => [12]];
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
self::assertArrayNotHasKey('diff', $dry);
$result = $ability->execute($input + ['confirm' => true]);
self::assertSame([], $result['diff']);
self::assertNull($result['design_rev']);
self::assertNull($result['snapshot_id']);
self::assertStringNotContainsString('lit/divi-presets-delete', $result['summary']);
}
public function testADefaultPresetLosesOnlyItsExplicitReferences(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('preview')
->with(Mockery::on(self::explicitQuery()), Mockery::on(self::detaching()), null)
->andReturn(PageRewriteReportTest::preview(1));
$dry = self::assertToolError(
$this->ability($pages)->execute(['preset' => ['module' => 'text', 'preset' => 'Fließtext']]),
'confirm_required'
);
self::assertSame(
['„Fließtext“ ist das Standard-Preset von divi/text: Divi wendet es weiter auf Module ohne eigenes '
. 'Preset an; entfernt werden nur die ausdrücklichen Verweise.'],
$dry['warnings']
);
self::assertArrayNotHasKey('diff', $dry, 'module presets are never nested');
}
public function testNothingLeftIsNoConfirmAndPointsToDelete(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('preview')->andReturn(PageRewriteReportTest::preview(0));
$result = $this->ability($pages)->execute(['preset' => ['module' => 'button', 'preset' => 'CTA primär']]);
self::assertTrue($result['ok']);
self::assertSame(
'Keine Verweise auf Modul-Preset divi/button „CTA primär“ gefunden – nichts zu tun. Jetzt kann '
. 'lit/divi-presets-delete das Preset löschen.',
$result['summary']
);
}
public function testRemainingPagesAskForAnotherCallWithoutTheDeleteHint(): void
{
$pages = Mockery::mock(PageRewriter::class);
$pages->expects('apply')->andReturn(PageRewriteReportTest::applied(2, 2));
$result = $this->ability($pages)->execute([
'preset' => ['module' => 'button', 'preset' => 'CTA primär'],
'confirm' => true,
]);
self::assertSame(2, $result['remaining']);
self::assertStringContainsString('Noch 2 Seiten offen', $result['summary']);
self::assertStringNotContainsString('lit/divi-presets-delete', $result['summary']);
}
public function testSchemaAndMeta(): void
{
$ability = $this->ability(self::untouched());
$schema = $ability->inputSchema();
self::assertSame('lit/divi-design-detach', $ability->name());
self::assertFalse($ability->isDirect());
self::assertSame(['readonly' => false, 'destructive' => false, 'idempotent' => true], $ability->annotations());
self::assertFalse($schema['additionalProperties']);
self::assertSame(['preset'], $schema['properties']['preset']['required']);
self::assertArrayHasKey('design_rev', $schema['properties']);
self::assertArrayHasKey('variable', $schema['properties']);
self::assertSame(['saved', 'remaining', 'skipped'], array_slice($ability->outputSchema()['required'], -3));
self::assertStringContainsString('lit/divi-presets-delete', $ability->description());
}
}