From 607e0a21caeae8d4a648fc1346d7876df8efbca3 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 18 Oct 2026 17:09:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lit/divi-design-detach=20=E2=80=93=20Pr?= =?UTF-8?q?eset-Verweise=20aus=20Seiten=20und=20verschachtelten=20Presets?= =?UTF-8?q?=20entfernen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../Divi/Design/DiviDesignDetachAbility.php | 391 ++++++++++++++++++ src/Integrations/Divi/Design/PresetBook.php | 48 +++ src/Integrations/Divi/DiviIntegration.php | 8 + .../Design/DiviDesignDetachAbilityTest.php | 325 +++++++++++++++ .../Divi/Design/PresetBookTest.php | 40 ++ .../Integrations/Divi/DiviIntegrationTest.php | 1 + 6 files changed, 813 insertions(+) create mode 100644 src/Integrations/Divi/Design/DiviDesignDetachAbility.php create mode 100644 tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php diff --git a/src/Integrations/Divi/Design/DiviDesignDetachAbility.php b/src/Integrations/Divi/Design/DiviDesignDetachAbility.php new file mode 100644 index 0000000..b796e94 --- /dev/null +++ b/src/Integrations/Divi/Design/DiviDesignDetachAbility.php @@ -0,0 +1,391 @@ +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 $input + * @return array{scope: PresetScope, id: string, name: string, default: bool, nested: bool, + * post_ids: list|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 $target + * @return array|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 $target + * @return array{diff: list>, 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 $target + * @return Closure(array): (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 $input + * @param array $target + * @return array|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 $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 $target + * @param list> $skipped + */ + private static function finished(array $target, array $skipped): bool + { + return $target['post_ids'] === null && $skipped === []; + } + + /** + * @param array $target + * @return list + */ + private static function notes(array $target): array + { + return $target['default'] ? [sprintf(self::DEFAULT_NOTE, $target['name'], $target['scope']->name)] : []; + } + + /** @param array $errors */ + private static function invalid(array $errors): WP_Error + { + return Errors::invalidInput('Ungültige Angaben; nichts wurde geändert.', $errors); + } +} diff --git a/src/Integrations/Divi/Design/PresetBook.php b/src/Integrations/Divi/Design/PresetBook.php index 2a3f0de..4dc3d32 100644 --- a/src/Integrations/Divi/Design/PresetBook.php +++ b/src/Integrations/Divi/Design/PresetBook.php @@ -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 $nested The groupPresets of one preset (formats §3). + * @return array + */ + 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)) { diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index b3c9c8c..8d039b1 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -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() + ), ]; } diff --git a/tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php new file mode 100644 index 0000000..333880f --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php @@ -0,0 +1,325 @@ + '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()); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/PresetBookTest.php b/tests/Unit/Integrations/Divi/Design/PresetBookTest.php index 400e7c6..5b95466 100644 --- a/tests/Unit/Integrations/Divi/Design/PresetBookTest.php +++ b/tests/Unit/Integrations/Divi/Design/PresetBookTest.php @@ -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')); + } } diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index d5bea0b..19cf3ed 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -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