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:
co-authored by
Claude Opus 5.5
parent
d4f235ae5e
commit
607e0a21ca
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user