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,391 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use InvalidArgumentException;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-design-detach (spec §6.8, ruling R10 – presets only): removes every explicit reference to one preset –
* from the modulePreset/groupPreset stacks of pages, library entries and Theme Builder layouts (DetachRewrite through
* the PageRewriter) and, for a group preset without post_ids, from the groupPresets of other presets (DesignChange,
* snapshot "presets"). Modules fall back to the next preset of their stack or the default. The dry run is ONE
* confirm_required with the pages and, when other presets nest it, the diff and design_rev of the preset step.
* Confirmed, the preset step runs first – its plan carries the page usages, so a blocked live post refuses before
* anything is written (P8a) – then at most PageRewriter::PAGE_LIMIT pages. A default preset keeps working implicitly
* (Divi applies it); only explicit references go. Variables follow with Etappe 4c.
*/
final class DiviDesignDetachAbility implements Ability
{
private const VARIABLES = 'Variablen folgen mit Etappe 4c.';
private const DELETE_HINT = 'Jetzt kann lit/divi-presets-delete das Preset löschen.';
private const DEFAULT_NOTE = '„%s“ ist das Standard-Preset von %s: Divi wendet es weiter auf Module ohne eigenes '
. 'Preset an; entfernt werden nur die ausdrücklichen Verweise.';
/** @param (Closure(): bool)|null $unfiltered Whether the user may save unfiltered HTML (DesignRights). */
public function __construct(
private DesignSystemWriter $writer,
private DesignChange $change,
private GlobalUsages $usages,
private PageRewriter $pages,
private ?Closure $unfiltered = null
) {
$this->unfiltered ??= DesignRights::unfilteredHtml();
}
public function name(): string
{
return 'lit/divi-design-detach';
}
public function label(): string
{
return 'Divi-Preset-Verweise entfernen';
}
public function description(): string
{
return 'Entfernt alle Verweise auf ein Preset (preset: module bzw. group + preset als Name oder ID) aus '
. 'Seiten, Bibliothekseinträgen und Theme-Builder-Layouts und – bei Gruppen-Presets – aus anderen Presets; '
. 'Module fallen auf das nächste Preset im Stapel bzw. das Standard-Preset zurück. post_ids beschränkt auf '
. 'diese Seiten (Verweise in Presets bleiben dann). Zuerst Probelauf (pages; bei Verweisen in Presets diff '
. 'und design_rev), nach Rückfrage beim Nutzer confirm: true (mit design_rev, wenn der Probelauf eine '
. 'nennt); je Aufruf höchstens 50 Seiten, remaining nennt den Rest. Mit post_ids können nur die '
. 'genannten Seiten das Schreiben blockieren, die Obergrenze für sehr viele Seiten gilt dann nicht, und '
. 'eine genannte Seite ohne Bearbeitungsrecht steht in skipped. Danach kann lit/divi-presets-delete '
. 'das Preset löschen. ' . self::VARIABLES;
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'preset' => [
'type' => 'object',
'properties' => [
'module' => ['type' => 'string'],
'group' => ['type' => 'string'],
'preset' => ['type' => 'string', 'description' => 'Name oder ID des Presets.'],
],
'required' => ['preset'],
],
'variable' => ['type' => 'string', 'description' => self::VARIABLES],
'post_ids' => PostIdsInput::schema(),
] + DesignChange::inputProperties(),
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return PageRewriteReport::outputSchema([
'diff' => ['type' => 'array', 'items' => ['type' => 'object']],
'design_rev' => ['type' => ['string', 'null']],
]);
}
public function annotations(): array
{
return Annotations::write(true);
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
return DesignRights::presets($this->writer) ?? true;
}
public function execute(array $input): mixed
{
$refusal = DesignRights::presets($this->writer);
if ($refusal !== null) {
return $refusal;
}
if (array_key_exists('variable', $input)) {
return Errors::unsupported(
'Verweise auf Variablen entfernt LIT MCP noch nicht; nichts wurde geändert.',
self::VARIABLES . ' Bis dahin nur Presets angeben (preset).'
);
}
$target = $this->target($input);
if ($target instanceof WP_Error) {
return $target;
}
return ($input['confirm'] ?? false) === true ? $this->confirmed($input, $target) : $this->dryRun($target);
}
/**
* The preset to detach, resolved on the live inventory.
*
* @param array<string, mixed> $input
* @return array{scope: PresetScope, id: string, name: string, default: bool, nested: bool,
* post_ids: list<int>|null, query: PresetUsageQuery, rewrite: Closure(): NodeRewrite, change: string}|WP_Error
*/
private function target(array $input): array|WP_Error
{
$preset = $input['preset'] ?? null;
if (! is_array($preset)) {
return self::invalid(['preset' => 'Objekt mit module oder group und preset (Name oder ID) erwartet']);
}
try {
$postIds = PostIdsInput::parse($input['post_ids'] ?? null);
} catch (InvalidArgumentException $e) {
return self::invalid(['post_ids' => $e->getMessage()]);
}
try {
$scope = PresetScope::fromInput($preset, 'preset');
} catch (InvalidArgumentException $e) {
[$path, $problem] = explode(': ', $e->getMessage(), 2) + [1 => ''];
return self::invalid([$path => $problem]);
}
$name = is_string($preset['preset'] ?? null) ? trim($preset['preset']) : '';
if ($name === '') {
return self::invalid(['preset.preset' => 'Name oder ID des Presets erwartet']);
}
$book = PresetBook::fromDivi($this->writer->presets());
try {
$found = $book->find($scope, $name);
} catch (InvalidArgumentException $e) {
return self::invalid(['preset.preset' => $e->getMessage()]);
}
if ($found === null) {
return Errors::notFound(
sprintf('Unbekanntes %s „%s“; nichts wurde geändert.', $scope->label(), $name),
'Vorhandene Presets mit Namen und IDs liefert lit/divi-design-system (direkt: lit-divi-design-system).'
);
}
$id = (string) $found['id'];
$label = is_string($found['name'] ?? null) ? $found['name'] : $id;
return [
'scope' => $scope,
'id' => $id,
'name' => $label,
'default' => $book->defaultId($scope) === $id,
'nested' => $postIds === null && $book->nestedUsers($id) !== [],
'post_ids' => $postIds,
// Explicit references only (R10), also for a default preset: no withGroups(), implicit uses stay.
'query' => new PresetUsageQuery([['scope' => $scope, 'id' => $id, 'default' => false]]),
'rewrite' => static fn (): NodeRewrite => new DetachRewrite($scope, $id),
'change' => sprintf('Entkoppeln von %s „%s“', $scope->label(), $label),
];
}
/**
* ONE confirm_required with the pages and, when other presets nest the preset, the preset step's diff and
* design_rev; a normal result when nothing references it any more.
*
* @param array<string, mixed> $target
* @return array<string, mixed>|WP_Error
*/
private function dryRun(array $target): array|WP_Error
{
$preview = $this->pages->preview($target['query'], $target['rewrite'], $target['post_ids']);
if ($preview instanceof WP_Error) {
return $preview;
}
$presets = $target['nested'] ? $this->presetDryRun($target) : [];
if ($presets instanceof WP_Error) {
return $presets;
}
$notes = self::notes($target);
if ($presets === [] && PageRewriteReport::isEmpty($preview)) {
$summary = sprintf(
'Keine Verweise auf %s „%s“ gefunden – nichts zu tun.',
$target['scope']->label(),
$target['name']
);
return PageRewriteReport::nothingToDo(
$summary . (self::finished($target, $preview['skipped']) ? ' ' . self::DELETE_HINT : ''),
$preview,
$notes
);
}
$message = PageRewriteReport::dryRunMessage($preview, $target['change']);
if ($presets !== []) {
$message .= sprintf(
' Zuerst entfernt der Aufruf die Verweise in %d anderen Presets (diff, mit Snapshot); dafür die '
. 'design_rev mit confirm: true mitsenden.',
count($presets['diff'])
);
}
return Errors::confirmRequired($message, $preview + $presets + ['warnings' => $notes]);
}
/**
* The preset step's dry run through DesignChange::run() without confirm: its confirm_required carries diff and
* design_rev; a result without error means nothing nests the preset any more.
*
* @param array<string, mixed> $target
* @return array{diff: list<array<string, mixed>>, design_rev: string}|array{}|WP_Error
*/
private function presetDryRun(array $target): array|WP_Error
{
$result = $this->change->run($this->name(), new PresetArea($this->writer), [], $this->nestedPlan($target));
if (! $result instanceof WP_Error) {
return [];
}
if (Errors::codeOf($result) !== 'confirm_required') {
return $result;
}
$data = (array) $result->get_error_data();
return ['diff' => $data['diff'] ?? [], 'design_rev' => (string) ($data['design_rev'] ?? '')];
}
/**
* @param array<string, mixed> $target
* @return Closure(array<array-key, mixed>): (DesignPlan|WP_Error)
*/
private function nestedPlan(array $target): Closure
{
return function (array $raw) use ($target): DesignPlan|WP_Error {
$before = PresetBook::fromDivi($raw);
$after = $before->withoutNestedRef($target['id']);
$diff = PresetDiff::between($before, $after);
if ($diff === []) {
return new DesignPlan($after->data(), [], PresetDiff::summary([]));
}
// The whole inventory is saved: HTML/CSS of any preset would be stripped without unfiltered_html (I-2).
$refusal = DesignRights::markup($this->unfiltered, $after->markupOutside([]));
if ($refusal !== null) {
return $refusal;
}
// The page usages ride along: a blocked live post refuses before the presets are written (P8a).
return new DesignPlan(
$after->data(),
$diff,
PresetDiff::summary($diff),
$this->usages->reportFor($target['query'])
);
};
}
/**
* Preset step first (stops on any error – nothing written), then at most PAGE_LIMIT pages.
*
* @param array<string, mixed> $input
* @param array<string, mixed> $target
* @return array<string, mixed>|WP_Error
*/
private function confirmed(array $input, array $target): array|WP_Error
{
$presets = null;
if ($target['nested']) {
$presets = $this->change->run(
$this->name(),
new PresetArea($this->writer),
$input,
$this->nestedPlan($target)
);
if ($presets instanceof WP_Error) {
return $presets;
}
}
$wrotePresets = $presets !== null && $presets['diff'] !== [];
$pages = $this->pages->apply(
$this->name(),
$target['query'],
$target['rewrite'],
$target['post_ids'],
$target['change']
);
if ($pages instanceof WP_Error) {
return $wrotePresets ? self::afterPresets($pages, $presets) : $pages;
}
$summary = PageRewriteReport::savedSummary($pages, $target['change']);
if ($wrotePresets) {
$summary = $presets['summary'] . ' ' . $summary;
}
if ($pages['remaining'] === 0 && self::finished($target, $pages['skipped'])) {
$summary .= ' ' . self::DELETE_HINT;
}
return ToolResult::ok($summary)
->snapshotId($presets['snapshot_id'] ?? null)
->logId($presets['log_id'] ?? null)
->warnings(array_merge($presets['warnings'] ?? [], $pages['warnings'], self::notes($target)))
->with('saved', $pages['saved'])
->with('remaining', $pages['remaining'])
->with('skipped', $pages['skipped'])
->with('diff', $presets['diff'] ?? [])
->with('design_rev', $presets['design_rev'] ?? null)
->toArray();
}
/**
* A page error after the preset step wrote: the client learns that the nested references are gone already.
*
* @param array<string, mixed> $presets The preset step's ToolResult.
*/
private static function afterPresets(WP_Error $error, array $presets): WP_Error
{
$data = (array) $error->get_error_data();
return Errors::make(
Errors::codeOf($error),
Errors::clientMessage($error) . sprintf(
' Die Verweise in anderen Presets sind bereits entfernt (Snapshot %s).',
(string) $presets['snapshot_id']
),
(string) ($data['hint'] ?? ''),
(int) ($data['status'] ?? 500),
array_diff_key($data, ['status' => true, 'hint' => true])
+ ['snapshot_id' => $presets['snapshot_id'], 'diff' => $presets['diff']]
);
}
/**
* Whether nothing is left to detach before lit/divi-presets-delete: all usages, nothing skipped.
*
* @param array<string, mixed> $target
* @param list<array<string, mixed>> $skipped
*/
private static function finished(array $target, array $skipped): bool
{
return $target['post_ids'] === null && $skipped === [];
}
/**
* @param array<string, mixed> $target
* @return list<string>
*/
private static function notes(array $target): array
{
return $target['default'] ? [sprintf(self::DEFAULT_NOTE, $target['name'], $target['scope']->name)] : [];
}
/** @param array<string, string> $errors */
private static function invalid(array $errors): WP_Error
{
return Errors::invalidInput('Ungültige Angaben; nichts wurde geändert.', $errors);
}
}
@@ -200,6 +200,32 @@ final class PresetBook
return $users;
}
/**
* The inventory without nested references to group preset $groupPresetId (lit/divi-design-detach, R10): the ID
* leaves the presetId stack of every groupPresets entry, an entry left without IDs is removed. Presets that do not
* nest it stay exactly as they are.
*/
public function withoutNestedRef(string $groupPresetId): self
{
$data = $this->data;
foreach ($this->data as $kind => $records) {
foreach ($records as $name => $record) {
foreach ($record['items'] as $id => $item) {
$nested = $item['groupPresets'] ?? null;
if (! is_array($nested)) {
continue;
}
$kept = self::withoutGroupPreset($nested, $groupPresetId);
if ($kept !== $nested) {
$data[$kind][$name]['items'][$id]['groupPresets'] = $kept;
}
}
}
}
return new self($data);
}
/**
* IDs of the presets outside $touchedIds whose settings (attrs, styleAttrs, renderAttrs) hold a string with "<"
* or ">" – HTML or CSS that Divi's prepare_data() strips when a user without unfiltered_html saves the inventory
@@ -254,6 +280,28 @@ final class PresetBook
return $id;
}
/**
* @param array<array-key, mixed> $nested The groupPresets of one preset (formats §3).
* @return array<array-key, mixed>
*/
private static function withoutGroupPreset(array $nested, string $groupPresetId): array
{
foreach ($nested as $groupId => $entry) {
$ids = is_array($entry) ? (array) ($entry['presetId'] ?? []) : [];
if (! in_array($groupPresetId, $ids, true)) {
continue;
}
$left = array_values(array_filter($ids, static fn (mixed $id): bool => $id !== $groupPresetId));
if ($left === []) {
unset($nested[$groupId]);
continue;
}
$nested[$groupId]['presetId'] = $left;
}
return $nested;
}
private static function hasMarkup(mixed $value): bool
{
if (is_string($value)) {
@@ -15,6 +15,7 @@ use LIT\MCP\Integrations\Divi\Design\DesignRights;
use LIT\MCP\Integrations\Divi\Design\DiviPresetGetAbility;
use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility;
use LIT\MCP\Integrations\Divi\Design\DiviDesignSnapshotsAbility;
use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsAssignAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsDeleteAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility;
@@ -192,6 +193,13 @@ final class DiviIntegration implements Integration
$this->pageRewriter($loader),
$this->presetAssignment(...)
),
new DiviDesignDetachAbility(
$this->designWriter,
$this->designChange(),
$this->globalUsages(),
$this->pageRewriter($loader),
DesignRights::unfilteredHtml()
),
];
}
@@ -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());
}
}
@@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Design\PresetBook;
use LIT\MCP\Integrations\Divi\Design\PresetDiff;
use LIT\MCP\Integrations\Divi\Design\PresetScope;
use PHPUnit\Framework\TestCase;
use stdClass;
@@ -146,4 +147,43 @@ final class PresetBookTest extends TestCase
self::assertMatchesRegularExpression('/^[0-9a-z]{10}$/', PresetBook::newId());
self::assertNotSame(PresetBook::newId(), PresetBook::newId());
}
public function testWithoutNestedRefRemovesTheIdFromEveryStack(): void
{
$stored = self::stored();
$stored['module']['divi/button']['items']['aaaaaaaaaa']['groupPresets'] = [
'button.decoration.font' => ['presetId' => ['hhhhhhhhhh', 'gggggggggg'], 'groupName' => 'divi/font'],
];
$book = PresetBook::fromDivi($stored);
$after = $book->withoutNestedRef('gggggggggg');
self::assertSame([], $after->nestedUsers('gggggggggg'));
self::assertSame([], $after->items(new PresetScope('module', 'divi/text'))['dddddddddd']['groupPresets']);
self::assertSame(
['button.decoration.font' => ['presetId' => ['hhhhhhhhhh'], 'groupName' => 'divi/font']],
$after->items(new PresetScope('module', 'divi/button'))['aaaaaaaaaa']['groupPresets']
);
self::assertCount(2, $book->nestedUsers('gggggggggg'), 'immutable');
self::assertSame(
[['dddddddddd', 'changed', ['groupPresets']], ['aaaaaaaaaa', 'changed', ['groupPresets']]],
array_map(
static fn (array $row): array => [$row['id'], $row['action'], $row['fields']],
array_reverse(PresetDiff::between($book, $after))
)
);
}
public function testWithoutNestedRefTakesAStringIdAndLeavesOthersAlone(): void
{
$book = PresetBook::fromDivi(self::stored());
self::assertSame($book->rev(), $book->withoutNestedRef('zzzzzzzzzz')->rev());
$stored = self::stored();
$text = &$stored['module']['divi/text']['items']['dddddddddd'];
$text['groupPresets']['content.decoration.bodyFont']['presetId'] = 'gggggggggg';
unset($text);
self::assertSame([], PresetBook::fromDivi($stored)->withoutNestedRef('gggggggggg')->nestedUsers('gggggggggg'));
}
}
@@ -37,6 +37,7 @@ final class DiviIntegrationTest extends TestCase
'lit/divi-design-snapshots',
'lit/divi-design-restore',
'lit/divi-presets-assign',
'lit/divi-design-detach',
];
public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void